From 1359c8bbb1a30109b1e4a08e933ad898b86a0971 Mon Sep 17 00:00:00 2001 From: Alex Turner Date: Tue, 12 May 2026 12:34:19 -0500 Subject: [PATCH 1/4] feat: Save slots --- pnpm-lock.yaml | 51 +++ src/saveslot/README.md | 23 ++ src/saveslot/default.project.json | 14 + src/saveslot/package.json | 51 +++ .../src/Client/Binders/HasSaveSlotsClient.lua | 116 ++++++ .../Client/Cmdr/SaveSlotCmdrServiceClient.lua | 49 +++ .../src/Client/SaveSlotServiceClient.lua | 55 +++ .../src/Server/Binders/HasSaveSlots.lua | 382 ++++++++++++++++++ .../src/Server/Cmdr/SaveSlotCmdrService.lua | 161 ++++++++ src/saveslot/src/Server/SaveSlotService.lua | 253 ++++++++++++ .../src/Shared/Cmdr/SaveSlotCmdrUtils.lua | 33 ++ .../src/Shared/Data/HasSaveSlotsData.lua | 14 + src/saveslot/src/Shared/Data/SaveSlotData.lua | 25 ++ src/saveslot/src/Shared/HasSaveSlotsBase.lua | 44 ++ .../src/Shared/HasSaveSlotsInterface.lua | 22 + src/saveslot/src/Shared/SaveSlotConstants.lua | 13 + .../src/Shared/SaveSlotDataService.lua | 141 +++++++ src/saveslot/src/node_modules.project.json | 7 + src/saveslot/test/default.project.json | 29 ++ .../test/scripts/Client/ClientMain.client.lua | 12 + .../test/scripts/Server/ServerMain.server.lua | 14 + 21 files changed, 1509 insertions(+) create mode 100644 src/saveslot/README.md create mode 100644 src/saveslot/default.project.json create mode 100644 src/saveslot/package.json create mode 100644 src/saveslot/src/Client/Binders/HasSaveSlotsClient.lua create mode 100644 src/saveslot/src/Client/Cmdr/SaveSlotCmdrServiceClient.lua create mode 100644 src/saveslot/src/Client/SaveSlotServiceClient.lua create mode 100644 src/saveslot/src/Server/Binders/HasSaveSlots.lua create mode 100644 src/saveslot/src/Server/Cmdr/SaveSlotCmdrService.lua create mode 100644 src/saveslot/src/Server/SaveSlotService.lua create mode 100644 src/saveslot/src/Shared/Cmdr/SaveSlotCmdrUtils.lua create mode 100644 src/saveslot/src/Shared/Data/HasSaveSlotsData.lua create mode 100644 src/saveslot/src/Shared/Data/SaveSlotData.lua create mode 100644 src/saveslot/src/Shared/HasSaveSlotsBase.lua create mode 100644 src/saveslot/src/Shared/HasSaveSlotsInterface.lua create mode 100644 src/saveslot/src/Shared/SaveSlotConstants.lua create mode 100644 src/saveslot/src/Shared/SaveSlotDataService.lua create mode 100644 src/saveslot/src/node_modules.project.json create mode 100644 src/saveslot/test/default.project.json create mode 100644 src/saveslot/test/scripts/Client/ClientMain.client.lua create mode 100644 src/saveslot/test/scripts/Server/ServerMain.server.lua diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c57f21f532e..afda4562c91 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4270,6 +4270,57 @@ importers: src/safedestroy: {} + src/saveslot: + dependencies: + '@quenty/adorneedata': + specifier: workspace:* + version: link:../adorneedata + '@quenty/baseobject': + specifier: workspace:* + version: link:../baseobject + '@quenty/binder': + specifier: workspace:* + version: link:../binder + '@quenty/brio': + specifier: workspace:* + version: link:../brio + '@quenty/cmdrservice': + specifier: workspace:* + version: link:../cmdrservice + '@quenty/datastore': + specifier: workspace:* + version: link:../datastore + '@quenty/instanceutils': + specifier: workspace:* + version: link:../instanceutils + '@quenty/loader': + specifier: workspace:* + version: link:../loader + '@quenty/maid': + specifier: workspace:* + version: link:../maid + '@quenty/playerbinder': + specifier: workspace:* + version: link:../playerbinder + '@quenty/promise': + specifier: workspace:* + version: link:../promise + '@quenty/remoting': + specifier: workspace:* + version: link:../remoting + '@quenty/rx': + specifier: workspace:* + version: link:../rx + '@quenty/table': + specifier: workspace:* + version: link:../table + '@quenty/tie': + specifier: workspace:* + version: link:../tie + '@quenty/valueobject': + specifier: workspace:* + version: link:../valueobject + src/scoredactionservice: dependencies: '@quenty/baseobject': diff --git a/src/saveslot/README.md b/src/saveslot/README.md new file mode 100644 index 00000000000..72550cf61ab --- /dev/null +++ b/src/saveslot/README.md @@ -0,0 +1,23 @@ +## SaveSlot + +
+ + Documentation status + + + Discord + + + Build and release status + +
+ +PlayerDataStoreService wrapper for save slots + +
View docs →
+ +## Installation + +``` +npm install @quenty/saveslot --save +``` diff --git a/src/saveslot/default.project.json b/src/saveslot/default.project.json new file mode 100644 index 00000000000..c954f9e26d9 --- /dev/null +++ b/src/saveslot/default.project.json @@ -0,0 +1,14 @@ +{ + "name": "saveslot", + "globIgnorePaths": [ + "**/.package-lock.json", + "**/.pnpm", + "**/.pnpm-workspace-state-v1.json", + "**/.modules.yaml", + "**/.ignored", + "**/.ignored_*" + ], + "tree": { + "$path": "src" + } +} diff --git a/src/saveslot/package.json b/src/saveslot/package.json new file mode 100644 index 00000000000..228ea0b5d1d --- /dev/null +++ b/src/saveslot/package.json @@ -0,0 +1,51 @@ +{ + "name": "@quenty/saveslot", + "version": "1.0.0", + "description": "PlayerDataStoreService wrapper for save slots", + "keywords": [ + "Roblox", + "Nevermore", + "Lua", + "saveslot" + ], + "bugs": { + "url": "https://github.com/Quenty/NevermoreEngine/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/Quenty/NevermoreEngine.git", + "directory": "src/saveslot/" + }, + "funding": { + "type": "patreon", + "url": "https://www.patreon.com/quenty" + }, + "license": "MIT", + "scripts": { + "preinstall": "npx only-allow pnpm" + }, + "contributors": [ + "Quenty" + ], + "dependencies": { + "@quenty/adorneedata": "workspace:*", + "@quenty/baseobject": "workspace:*", + "@quenty/binder": "workspace:*", + "@quenty/brio": "workspace:*", + "@quenty/cmdrservice": "workspace:*", + "@quenty/datastore": "workspace:*", + "@quenty/instanceutils": "workspace:*", + "@quenty/loader": "workspace:*", + "@quenty/maid": "workspace:*", + "@quenty/playerbinder": "workspace:*", + "@quenty/promise": "workspace:*", + "@quenty/remoting": "workspace:*", + "@quenty/rx": "workspace:*", + "@quenty/table": "workspace:*", + "@quenty/tie": "workspace:*", + "@quenty/valueobject": "workspace:*" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/src/saveslot/src/Client/Binders/HasSaveSlotsClient.lua b/src/saveslot/src/Client/Binders/HasSaveSlotsClient.lua new file mode 100644 index 00000000000..9352f84af5e --- /dev/null +++ b/src/saveslot/src/Client/Binders/HasSaveSlotsClient.lua @@ -0,0 +1,116 @@ +--!strict +--[=[ + @class HasSaveSlotsClient +]=] + +local require = require(script.Parent.loader).load(script) + +local Players = game:GetService("Players") + +local Binder = require("Binder") +local HasSaveSlotsBase = require("HasSaveSlotsBase") +local HasSaveSlotsInterface = require("HasSaveSlotsInterface") +local Promise = require("Promise") +local Remoting = require("Remoting") +local SaveSlotData = require("SaveSlotData") +local ServiceBag = require("ServiceBag") + +local HasSaveSlotsClient = setmetatable({}, HasSaveSlotsBase) +HasSaveSlotsClient.ClassName = "HasSaveSlotsClient" +HasSaveSlotsClient.__index = HasSaveSlotsClient + +export type HasSaveSlotsClient = + typeof(setmetatable( + {} :: { + _obj: Player, + _serviceBag: ServiceBag.ServiceBag, + _remoting: any, + }, + {} :: typeof({ __index = HasSaveSlotsClient }) + )) + & HasSaveSlotsBase.HasSaveSlotsBase + +function HasSaveSlotsClient.new(player: Player, serviceBag: ServiceBag.ServiceBag): HasSaveSlotsClient + if player ~= Players.LocalPlayer then + return nil :: any + end + + local self: HasSaveSlotsClient = setmetatable(HasSaveSlotsBase.new(player, serviceBag) :: any, HasSaveSlotsClient) + + self._serviceBag = assert(serviceBag, "No serviceBag") + + self._remoting = self._maid:Add(Remoting.Client.new(self._obj, "HasSaveSlots")) + + self._maid:GiveTask(HasSaveSlotsInterface.Client:Implement(self._obj, self)) + + return self +end + +--[=[ + Returns whether the slot at the given index exists +]=] +function HasSaveSlotsClient.PromiseHasSlot(self: HasSaveSlotsClient, slotIndex: number): Promise.Promise + return self._remoting.PromiseHasSlot:PromiseInvokeServer(slotIndex) +end + +--[=[ + Selects the slot at the given index +]=] +function HasSaveSlotsClient.PromiseSelectSlot(self: HasSaveSlotsClient, slotIndex: number): Promise.Promise + return self._remoting.PromiseSelectSlot:PromiseInvokeServer(slotIndex) +end + +--[=[ + Creates a slot at the given index +]=] +function HasSaveSlotsClient.PromiseCreateSlot( + self: HasSaveSlotsClient, + slotIndex: number, + metadata: SaveSlotData.SaveSlotMetadata? +): Promise.Promise + return self._remoting.PromiseCreateSlot:PromiseInvokeServer(slotIndex, metadata) +end + +--[=[ + Deletes the slot at the given index +]=] +function HasSaveSlotsClient.PromiseDeleteSlot(self: HasSaveSlotsClient, slotIndex: number): Promise.Promise + return self._remoting.PromiseDeleteSlot:PromiseInvokeServer(slotIndex) +end + +--[=[ + Sets the metadata for the slot at the given index +]=] +function HasSaveSlotsClient.PromiseSetSlotMetadata( + self: HasSaveSlotsClient, + slotIndex: number, + data: SaveSlotData.SaveSlotMetadata +): Promise.Promise + return self._remoting.PromiseSetSlotMetadata:PromiseInvokeServer(slotIndex, data) +end + +--[=[ + Gets the metadata for the slot at the given index +]=] +function HasSaveSlotsClient.PromiseGetSlotMetadata( + self: HasSaveSlotsClient, + slotIndex: number +): Promise.Promise + return self._remoting.PromiseGetSlotMetadata:PromiseInvokeServer(slotIndex) +end + +--[=[ + Gets the last active slot index +]=] +function HasSaveSlotsClient.PromiseLastActiveSlotIndex(self: HasSaveSlotsClient): Promise.Promise + return self._remoting.PromiseLastActiveSlotIndex:PromiseInvokeServer() +end + +--[=[ + Refreshes the active slot summary +]=] +function HasSaveSlotsClient.PromiseRefreshActiveSlotSummary(self: HasSaveSlotsClient): Promise.Promise + return self._remoting.PromiseRefreshActiveSlotSummary:PromiseInvokeServer() +end + +return Binder.new("HasSaveSlots", HasSaveSlotsClient :: any) :: Binder.Binder diff --git a/src/saveslot/src/Client/Cmdr/SaveSlotCmdrServiceClient.lua b/src/saveslot/src/Client/Cmdr/SaveSlotCmdrServiceClient.lua new file mode 100644 index 00000000000..d0a2b4bca30 --- /dev/null +++ b/src/saveslot/src/Client/Cmdr/SaveSlotCmdrServiceClient.lua @@ -0,0 +1,49 @@ +--!strict +--[=[ + @class SaveSlotCmdrServiceClient +]=] + +local require = require(script.Parent.loader).load(script) + +local CmdrServiceClient = require("CmdrServiceClient") +local Maid = require("Maid") +local SaveSlotCmdrUtils = require("SaveSlotCmdrUtils") +local SaveSlotDataService = require("SaveSlotDataService") +local ServiceBag = require("ServiceBag") + +local SaveSlotCmdrServiceClient = {} +SaveSlotCmdrServiceClient.ServiceName = "SaveSlotCmdrServiceClient" + +export type SaveSlotCmdrServiceClient = typeof(setmetatable( + {} :: { + _serviceBag: ServiceBag.ServiceBag, + _maid: Maid.Maid, + _cmdrServiceClient: any, + _saveSlotDataService: any, + }, + {} :: typeof({ __index = SaveSlotCmdrServiceClient }) +)) + +function SaveSlotCmdrServiceClient.Init(self: SaveSlotCmdrServiceClient, serviceBag: ServiceBag.ServiceBag) + assert(not (self :: any)._serviceBag, "Already initialized") + self._serviceBag = assert(serviceBag, "No serviceBag") + self._maid = Maid.new() + + -- External + self._cmdrServiceClient = self._serviceBag:GetService(CmdrServiceClient) + + -- Internal + self._saveSlotDataService = self._serviceBag:GetService(SaveSlotDataService) +end + +function SaveSlotCmdrServiceClient.Start(self: SaveSlotCmdrServiceClient) + self._maid:GivePromise(self._cmdrServiceClient:PromiseCmdr()):Then(function(cmdr) + SaveSlotCmdrUtils.registerSlotIndexType(cmdr, self._saveSlotDataService) + end) +end + +function SaveSlotCmdrServiceClient.Destroy(self: SaveSlotCmdrServiceClient): () + self._maid:Destroy() +end + +return SaveSlotCmdrServiceClient diff --git a/src/saveslot/src/Client/SaveSlotServiceClient.lua b/src/saveslot/src/Client/SaveSlotServiceClient.lua new file mode 100644 index 00000000000..7d7929dc4b3 --- /dev/null +++ b/src/saveslot/src/Client/SaveSlotServiceClient.lua @@ -0,0 +1,55 @@ +--!strict +--[=[ + @class SaveSlotServiceClient +]=] + +local require = require(script.Parent.loader).load(script) + +local ReplicatedStorage = game:GetService("ReplicatedStorage") + +local Maid = require("Maid") +local Remoting = require("Remoting") +local ServiceBag = require("ServiceBag") + +local SaveSlotServiceClient = {} +SaveSlotServiceClient.ServiceName = "SaveSlotServiceClient" + +export type SaveSlotServiceClient = typeof(setmetatable( + {} :: { + _serviceBag: ServiceBag.ServiceBag, + _maid: Maid.Maid, + _remoting: any, + }, + {} :: typeof({ __index = SaveSlotServiceClient }) +)) + +function SaveSlotServiceClient.Init(self: SaveSlotServiceClient, serviceBag: ServiceBag.ServiceBag) + assert(not (self :: any)._serviceBag, "Already initialized") + self._serviceBag = assert(serviceBag, "No serviceBag") + self._maid = Maid.new() + + -- Internal + self._serviceBag:GetService(require("SaveSlotCmdrServiceClient")) + self._serviceBag:GetService(require("SaveSlotDataService")) + + -- Binders + self._serviceBag:GetService(require("HasSaveSlotsClient")) + + self._remoting = self._maid:Add(Remoting.Client.new(ReplicatedStorage, "SaveSlotService")) +end + +--[=[ + Returns whether explicit slot selection is required +]=] +function SaveSlotServiceClient.GetExplicitSelectionRequiredAsync(self: SaveSlotServiceClient): boolean + return self._remoting.GetExplicitSelectionRequired:InvokeServer() +end + +--[=[ + Destroys the service +]=] +function SaveSlotServiceClient.Destroy(self: SaveSlotServiceClient): () + self._maid:Destroy() +end + +return SaveSlotServiceClient diff --git a/src/saveslot/src/Server/Binders/HasSaveSlots.lua b/src/saveslot/src/Server/Binders/HasSaveSlots.lua new file mode 100644 index 00000000000..2bae93f274e --- /dev/null +++ b/src/saveslot/src/Server/Binders/HasSaveSlots.lua @@ -0,0 +1,382 @@ +--!strict +--[=[ + @class HasSaveSlots +]=] + +local require = require(script.Parent.loader).load(script) + +local Binder = require("Binder") +local HasSaveSlotsBase = require("HasSaveSlotsBase") +local HasSaveSlotsInterface = require("HasSaveSlotsInterface") +local Maid = require("Maid") +local PlayerBinder = require("PlayerBinder") +local PlayerDataStoreService = require("PlayerDataStoreService") +local Promise = require("Promise") +local Remoting = require("Remoting") +local SaveSlotConstants = require("SaveSlotConstants") +local SaveSlotData = require("SaveSlotData") +local ServiceBag = require("ServiceBag") + +type SaveSlot = { + folder: Folder, + attributes: any, + maid: Maid.Maid, +} + +local HasSaveSlots = setmetatable({}, HasSaveSlotsBase) +HasSaveSlots.ClassName = "HasSaveSlots" +HasSaveSlots.__index = HasSaveSlots + +export type HasSaveSlots = + typeof(setmetatable( + {} :: { + _obj: Player, + _serviceBag: ServiceBag.ServiceBag, + _playerDataStoreService: any, + _slotContainer: Folder, + _slotMap: { [number]: SaveSlot }, + _loadPromise: Promise.Promise<{}>, + _remoting: any, + _dataStore: any, + _metadataStore: any, + _summaryProvider: ((Player, any) -> string)?, + _lastActiveSlotIndex: number?, + }, + {} :: typeof({ __index = HasSaveSlots }) + )) + & HasSaveSlotsBase.HasSaveSlotsBase + +function HasSaveSlots.new(player: Player, serviceBag: ServiceBag.ServiceBag): HasSaveSlots + local self: HasSaveSlots = setmetatable(HasSaveSlotsBase.new(player, serviceBag) :: any, HasSaveSlots) + + self._serviceBag = assert(serviceBag, "No serviceBag") + self._playerDataStoreService = self._serviceBag:GetService(PlayerDataStoreService) + + self._slotContainer = self._maid:Add(Instance.new("Folder")) + self._slotContainer.Name = SaveSlotConstants.METADATA_CONTAINER_NAME + self._slotContainer.Archivable = false + self._slotContainer.Parent = self._obj + + self._slotMap = {} + + self._loadPromise = self._maid:GivePromise(self:_promiseLoadSlots()) + + self._remoting = self._maid:Add(Remoting.Server.new(self._obj, "HasSaveSlots")) + + self:_setupRemotes() + + self._maid:GiveTask(HasSaveSlotsInterface.Server:Implement(self._obj, self)) + + return self +end + +--[=[ + Promises that all slots have loaded +]=] +function HasSaveSlots.PromiseSlotsLoaded(self: HasSaveSlots): Promise.Promise + return self._loadPromise +end + +--[=[ + Returns whether the slot at the given index exists +]=] +function HasSaveSlots.PromiseHasSlot(self: HasSaveSlots, slotIndex: number): Promise.Promise + return (self._loadPromise :: any):Then(function() + return (self._slotMap[slotIndex] ~= nil) + end) +end + +--[=[ + Selects the slot at the given index +]=] +function HasSaveSlots.PromiseSelectSlot(self: HasSaveSlots, slotIndex: number): Promise.Promise + return (self._loadPromise :: any):Then(function() + if slotIndex == self.ActiveSlotIndex.Value then + return -- Already set + end + + local slot = self._slotMap[slotIndex] + if not slot then + return (Promise :: any).rejected(`Slot {slotIndex} not found`) + end + + local function setSlot() + self.ActiveSlotIndex.Value = slotIndex + slot.attributes.LastPlayedTime.Value = os.time() + end + + -- Initialize or save and switch + if self.ActiveSlotIndex.Value == nil then + setSlot() + return + end + + return self._dataStore:Save():Then(setSlot) + end) +end + +--[=[ + Creates a slot at the given index +]=] +function HasSaveSlots.PromiseCreateSlot( + self: HasSaveSlots, + slotIndex: number, + metadata: SaveSlotData.SaveSlotMetadata? +): Promise.Promise + return (self._loadPromise :: any):Then(function() + if self._slotMap[slotIndex] then + return (Promise :: any).rejected(`Slot {slotIndex} already exists`) + end + + if slotIndex > self.MaxSlotCount.Value then + return (Promise :: any).rejected(`Index {slotIndex} exceeds max of {self.MaxSlotCount.Value}`) + end + + local data = { + SlotIndex = slotIndex, + SlotName = (metadata and metadata.SlotName) or `Slot {slotIndex}`, + CreatedTime = os.time(), + Summary = metadata and metadata.Summary, + } + + self:_buildSlot(slotIndex, data, true) + end) +end + +--[=[ + Deletes the slot at the given index +]=] +function HasSaveSlots.PromiseDeleteSlot(self: HasSaveSlots, slotIndex: number): Promise.Promise + return (self._loadPromise :: any):Then(function() + if slotIndex == self.ActiveSlotIndex.Value then + return (Promise :: any).rejected("Cannot delete active slot") + end + + local slot = self._slotMap[slotIndex] + if not slot then + return (Promise :: any).rejected(`Slot {slotIndex} not found`) + end + + slot.maid:Destroy() + + local slotKey = tostring(slotIndex) + self._metadataStore:Delete(slotKey) + self._dataStore:GetSubStore("saveSlots"):Delete(slotKey) + end) +end + +--[=[ + Sets the metadata for the slot at the given index +]=] +function HasSaveSlots.PromiseSetSlotMetadata( + self: HasSaveSlots, + slotIndex: number, + data: SaveSlotData.SaveSlotMetadata +): Promise.Promise + assert(data.SlotIndex == nil or data.SlotIndex == slotIndex, "SlotIndex is locked") + + return (self._loadPromise :: any):Then(function() + local slot = self._slotMap[slotIndex] + SaveSlotData:Set(slot.folder, data) + end) +end + +--[=[ + Gets the metadata for the slot at the given index +]=] +function HasSaveSlots.PromiseGetSlotMetadata( + self: HasSaveSlots, + slotIndex: number +): Promise.Promise + return (self._loadPromise :: any):Then(function() + local slot = self._slotMap[slotIndex] + return (Promise :: any).resolved(slot and slot.attributes) + end) +end + +--[=[ + Gets the last active slot index +]=] +function HasSaveSlots.PromiseLastActiveSlotIndex(self: HasSaveSlots): Promise.Promise + return (self._loadPromise :: any):Then(function() + return self.ActiveSlotIndex.Value or self._lastActiveSlotIndex + end) +end + +--[=[ + Sets the summary provider callback +]=] +function HasSaveSlots.SetSummaryProvider(self: HasSaveSlots, provider: ((Player, any) -> string)?): () + self._summaryProvider = provider +end + +--[=[ + Refreshes the active slot summary +]=] +function HasSaveSlots.PromiseRefreshActiveSlotSummary(self: HasSaveSlots): Promise.Promise + return (self._loadPromise :: any):Then(function() + self:_refreshActiveSlotSummary() + end) +end + +function HasSaveSlots._promiseLoadSlots(self: HasSaveSlots): Promise.Promise<{}> + return self._maid:GivePromise(self._playerDataStoreService:PromiseDataStore(self._obj)):Then(function(dataStore) + self._dataStore = dataStore + self._metadataStore = dataStore:GetSubStore("saveSlotMetadata") + + self._maid:GiveTask(self._dataStore:AddSavingCallback(function() + self:_refreshActiveSlotSummary() + end)) + + return self._metadataStore:LoadAll({}):Then(function(metadata) + for key, data in metadata do + local slotIndex = tonumber(key) + if slotIndex then + self:_buildSlot(slotIndex, data) + end + end + + return dataStore:Load("ActiveSlotIndex"):Then(function(activeIndex: number?) + self._lastActiveSlotIndex = activeIndex + self._maid:GiveTask(dataStore:StoreOnValueChange("ActiveSlotIndex", self.ActiveSlotIndex)) + end) + end) + end) +end + +function HasSaveSlots._buildSlot( + self: HasSaveSlots, + slotIndex: number, + data: SaveSlotData.SaveSlotMetadata, + isNew: boolean? +): () + local maid = self._maid:Add(Maid.new()) + + local folder = maid:Add(Instance.new("Folder")) + folder.Name = tostring(slotIndex) + folder.Archivable = false + + local attributes = SaveSlotData:Create(folder) + attributes.SlotIndex.Value = slotIndex + + local slotStore = self._metadataStore:GetSubStore(tostring(slotIndex)) + + for _, key in { "SlotName", "CreatedTime", "LastPlayedTime", "Summary" } do + attributes[key].Value = data[key] + maid:GiveTask(slotStore:StoreOnValueChange(key, attributes[key])) + + if isNew then + slotStore:Store(key, attributes[key].Value) + end + end + + folder.Parent = self._slotContainer + + self._slotMap[slotIndex] = { + folder = folder, + attributes = attributes, + maid = maid, + } + + maid:GiveTask(function() + self._slotMap[slotIndex] = nil + end) +end + +function HasSaveSlots._refreshActiveSlotSummary(self: HasSaveSlots): () + if not self._summaryProvider then + return -- No summary provider + end + + local activeSlotIndex = self.ActiveSlotIndex.Value + local activeSlot = activeSlotIndex and self._slotMap[activeSlotIndex] + if not activeSlot then + return -- No active slot + end + + local slotStore = self._dataStore:GetSubStore("saveSlots"):GetSubStore(tostring(activeSlotIndex)) + local success, result = pcall(self._summaryProvider, self._obj, slotStore) + + if not success then + warn(`[HasSaveSlots] Summary provider errored: {result}`) + return + end + + if type(result) ~= "string" then + warn(`[HasSaveSlots] Summary provider returned non-string ({typeof(result)})`) + return + end + + activeSlot.attributes.Summary.Value = result + + -- Store directly to avoid deferral during save callback + self._metadataStore:GetSubStore(tostring(activeSlotIndex)):Store("Summary", result) +end + +function HasSaveSlots._setupRemotes(self: HasSaveSlots): () + self._maid:GiveTask(self._remoting.PromiseHasSlot:Bind(function(remotePlayer, ...) + if remotePlayer == self._obj then + return self:PromiseHasSlot(...) + else + return (Promise :: any).rejected("Bad player") + end + end)) + + self._maid:GiveTask(self._remoting.PromiseSelectSlot:Bind(function(remotePlayer, ...) + if remotePlayer == self._obj then + return self:PromiseSelectSlot(...) + else + return (Promise :: any).rejected("Bad player") + end + end)) + + self._maid:GiveTask(self._remoting.PromiseCreateSlot:Bind(function(remotePlayer, ...) + if remotePlayer == self._obj then + return self:PromiseCreateSlot(...) + else + return (Promise :: any).rejected("Bad player") + end + end)) + + self._maid:GiveTask(self._remoting.PromiseDeleteSlot:Bind(function(remotePlayer, ...) + if remotePlayer == self._obj then + return self:PromiseDeleteSlot(...) + else + return (Promise :: any).rejected("Bad player") + end + end)) + + self._maid:GiveTask(self._remoting.PromiseSetSlotMetadata:Bind(function(remotePlayer, ...) + if remotePlayer == self._obj then + return self:PromiseSetSlotMetadata(...) + else + return (Promise :: any).rejected("Bad player") + end + end)) + + self._maid:GiveTask(self._remoting.PromiseGetSlotMetadata:Bind(function(remotePlayer, ...) + if remotePlayer == self._obj then + return self:PromiseGetSlotMetadata(...) + else + return (Promise :: any).rejected("Bad player") + end + end)) + + self._maid:GiveTask(self._remoting.PromiseLastActiveSlotIndex:Bind(function(remotePlayer) + if remotePlayer == self._obj then + return self:PromiseLastActiveSlotIndex() + else + return (Promise :: any).rejected("Bad player") + end + end)) + + self._maid:GiveTask(self._remoting.PromiseRefreshActiveSlotSummary:Bind(function(remotePlayer) + if remotePlayer == self._obj then + return self:PromiseRefreshActiveSlotSummary() + else + return (Promise :: any).rejected("Bad player") + end + end)) +end + +return PlayerBinder.new("HasSaveSlots", HasSaveSlots :: any) :: Binder.Binder diff --git a/src/saveslot/src/Server/Cmdr/SaveSlotCmdrService.lua b/src/saveslot/src/Server/Cmdr/SaveSlotCmdrService.lua new file mode 100644 index 00000000000..ef08a33ae74 --- /dev/null +++ b/src/saveslot/src/Server/Cmdr/SaveSlotCmdrService.lua @@ -0,0 +1,161 @@ +--!strict +--[=[ + @class SaveSlotCmdrService +]=] + +local require = require(script.Parent.loader).load(script) + +local CmdrService = require("CmdrService") +local HasSaveSlots = require("HasSaveSlots") +local Maid = require("Maid") +local SaveSlotCmdrUtils = require("SaveSlotCmdrUtils") +local SaveSlotDataService = require("SaveSlotDataService") +local ServiceBag = require("ServiceBag") + +local SaveSlotCmdrService = {} +SaveSlotCmdrService.ServiceName = "SaveSlotCmdrService" + +export type SaveSlotCmdrService = typeof(setmetatable( + {} :: { + _serviceBag: ServiceBag.ServiceBag, + _maid: Maid.Maid, + _cmdrService: any, + _hasSaveSlotsBinder: any, + _saveSlotDataService: any, + }, + {} :: typeof({ __index = SaveSlotCmdrService }) +)) + +function SaveSlotCmdrService.Init(self: SaveSlotCmdrService, serviceBag: ServiceBag.ServiceBag) + assert(not (self :: any)._serviceBag, "Already initialized") + self._serviceBag = assert(serviceBag, "No serviceBag") + self._maid = Maid.new() + + -- External + self._cmdrService = self._serviceBag:GetService(CmdrService) + + -- Internal + self._hasSaveSlotsBinder = self._serviceBag:GetService(HasSaveSlots) + self._saveSlotDataService = self._serviceBag:GetService(SaveSlotDataService) +end + +function SaveSlotCmdrService.Start(self: SaveSlotCmdrService) + self._maid:GivePromise(self._cmdrService:PromiseCmdr()):Then(function(cmdr) + SaveSlotCmdrUtils.registerSlotIndexType(cmdr, self._saveSlotDataService) + self:_registerCommands() + end) +end + +function SaveSlotCmdrService._registerCommands(self: SaveSlotCmdrService): () + self._cmdrService:RegisterCommand({ + Name = "list-save-slots", + Description = "Lists all save slots.", + Group = "SaveSlots", + Args = {}, + }, function(context) + local slotList = self._saveSlotDataService:GetSlotList(context.Executor) + local listString = "" + + for _, slot in slotList do + local isActive = (slot.SlotIndex == self._saveSlotDataService:GetActiveSlotIndex(context.Executor)) + listString ..= `\n"{slot.SlotName}" ({slot.SlotIndex}){isActive and " — Active" or ""}\n{slot.Summary}\n` + end + + return listString + end) + + self._cmdrService:RegisterCommand({ + Name = "active-save-slot", + Description = "Returns the active save slot.", + Group = "SaveSlots", + Args = {}, + }, function(context) + local slotIndex = self._saveSlotDataService:GetActiveSlotIndex(context.Executor) + local slotData = self._saveSlotDataService:GetSlotMetadata(context.Executor, slotIndex) + + return `Currently using slot {slotIndex} ("{slotData.SlotName}").` + end) + + self._cmdrService:RegisterCommand({ + Name = "set-save-slot", + Description = "Switches to the given save slot.", + Group = "SaveSlots", + Args = { + { + Name = "Slot", + Type = "slotIndex", + Description = "Slot index to switch to.", + }, + }, + }, function(context, slotIndex: number) + self._maid + :GivePromise(self._hasSaveSlotsBinder:Promise(context.Executor)) + :Then(function(hasSaveSlots) + return hasSaveSlots:PromiseSelectSlot(slotIndex) + end) + :Wait() + + return `Switched to slot {slotIndex}.` + end) + + self._cmdrService:RegisterCommand({ + Name = "create-save-slot", + Description = "Creates a save slot at the given index.", + Group = "SaveSlots", + Args = { + { + Name = "Slot", + Type = "number", + Description = "Slot index to create.", + }, + }, + }, function(context, slotIndex: number) + local maxSlotCount = context.Executor:GetAttribute("MaxSlotCount") + if (slotIndex < 1) or (slotIndex > maxSlotCount) then + return `Index must be in range [1, {maxSlotCount}].` + end + + local hasSaveSlots = self._maid:GivePromise(self._hasSaveSlotsBinder:Promise(context.Executor)):Wait() + + local hasSlot = self._maid:GivePromise(hasSaveSlots:PromiseHasSlot(slotIndex)):Wait() + if hasSlot then + return "Slot already exists." + end + + self._maid:GivePromise(hasSaveSlots:PromiseCreateSlot(slotIndex)):Wait() + + return `Created slot {slotIndex}.` + end) + + self._cmdrService:RegisterCommand({ + Name = "delete-save-slot", + Description = "Deletes the given save slot.", + Group = "SaveSlots", + Args = { + { + Name = "Slot", + Type = "slotIndex", + Description = "Slot index to delete.", + }, + }, + }, function(context, slotIndex: number) + if slotIndex == self._saveSlotDataService:GetActiveSlotIndex(context.Executor) then + return "Cannot delete active slot." + end + + self._maid + :GivePromise(self._hasSaveSlotsBinder:Promise(context.Executor)) + :Then(function(hasSaveSlots) + return hasSaveSlots:PromiseDeleteSlot(slotIndex) + end) + :Wait() + + return `Deleted slot {slotIndex}.` + end) +end + +function SaveSlotCmdrService.Destroy(self: SaveSlotCmdrService): () + self._maid:Destroy() +end + +return SaveSlotCmdrService diff --git a/src/saveslot/src/Server/SaveSlotService.lua b/src/saveslot/src/Server/SaveSlotService.lua new file mode 100644 index 00000000000..ff500ec447e --- /dev/null +++ b/src/saveslot/src/Server/SaveSlotService.lua @@ -0,0 +1,253 @@ +--!strict +--[=[ + @class SaveSlotService +]=] + +local require = require(script.Parent.loader).load(script) + +local ReplicatedStorage = game:GetService("ReplicatedStorage") + +local Brio = require("Brio") +local DataStoreStage = require("DataStoreStage") +local HasSaveSlots = require("HasSaveSlots") +local Maid = require("Maid") +local Observable = require("Observable") +local PlayerDataStoreService = require("PlayerDataStoreService") +local Promise = require("Promise") +local Remoting = require("Remoting") +local Rx = require("Rx") +local RxBrioUtils = require("RxBrioUtils") +local SaveSlotConstants = require("SaveSlotConstants") +local SaveSlotData = require("SaveSlotData") +local ServiceBag = require("ServiceBag") + +local SaveSlotService = {} +SaveSlotService.ServiceName = "SaveSlotService" + +export type SaveSlotService = typeof(setmetatable( + {} :: { + _serviceBag: ServiceBag.ServiceBag, + _maid: Maid.Maid, + _playerDataStoreService: any, + _hasSaveSlotsBinder: any, + _selectionRequired: boolean, + _maxSlotCount: number, + _summaryProvider: ((Player, any) -> string)?, + _remoting: any, + }, + {} :: typeof({ __index = SaveSlotService }) +)) + +function SaveSlotService.Init(self: SaveSlotService, serviceBag: ServiceBag.ServiceBag) + assert(not (self :: any)._serviceBag, "Already initialized") + self._serviceBag = assert(serviceBag, "No serviceBag") + self._maid = Maid.new() + + -- External + self._playerDataStoreService = self._serviceBag:GetService(PlayerDataStoreService) + + -- Internal + self._serviceBag:GetService(require("SaveSlotCmdrService")) + self._serviceBag:GetService(require("SaveSlotDataService")) + + -- Binders + self._hasSaveSlotsBinder = self._serviceBag:GetService(HasSaveSlots) + + self._selectionRequired = false + self._maxSlotCount = 1 + + self._remoting = self._maid:Add(Remoting.Server.new(ReplicatedStorage, "SaveSlotService")) + + self._maid:GiveTask(self._remoting.GetExplicitSelectionRequired:Bind(function() + return self:GetExplicitSelectionRequired() + end)) +end + +function SaveSlotService.Start(self: SaveSlotService) + self._maid:GiveTask(self._hasSaveSlotsBinder:ObserveAllBrio():Subscribe(function(brio) + if brio:IsDead() then + return + end + + local maid, hasSaveSlots = brio:ToMaidAndValue() + + -- Pass consumer-specified configs + hasSaveSlots.MaxSlotCount.Value = self._maxSlotCount + if self._summaryProvider then + hasSaveSlots:SetSummaryProvider(self._summaryProvider) + end + + maid:GivePromise(hasSaveSlots:PromiseSlotsLoaded()):Then(function() + if self._selectionRequired then + return -- Consumer handles selection + end + + -- Select last active slot + return hasSaveSlots:PromiseLastActiveSlotIndex():Then(function(lastActiveSlotIndex: number?) + return hasSaveSlots:PromiseHasSlot(lastActiveSlotIndex):Then(function(hasLastSlot: boolean) + if hasLastSlot then + return hasSaveSlots:PromiseSelectSlot(lastActiveSlotIndex) + end + + -- Or create and select default slot + return hasSaveSlots + :PromiseHasSlot(SaveSlotConstants.DEFAULT_SLOT_INDEX) + :Then(function(hasDefaultSlot: boolean) + if not hasDefaultSlot then + return hasSaveSlots:PromiseCreateSlot(SaveSlotConstants.DEFAULT_SLOT_INDEX) + end + end) + :Then(function() + return hasSaveSlots:PromiseSelectSlot(SaveSlotConstants.DEFAULT_SLOT_INDEX) + end) + end) + end) + end) + end)) +end + +--[=[ + Requires explicit slot selection +]=] +function SaveSlotService.RequireExplicitSelection(self: SaveSlotService): () + assert(not self._serviceBag:IsStarted(), "RequireExplicitSelection must be called before Start") + self._selectionRequired = true +end + +--[=[ + Returns whether explicit slot selection is required +]=] +function SaveSlotService.GetExplicitSelectionRequired(self: SaveSlotService): boolean + return self._selectionRequired +end + +--[=[ + Sets the max slot count +]=] +function SaveSlotService.SetMaxSlotCount(self: SaveSlotService, maxSlotCount: number): () + assert(not self._serviceBag:IsStarted(), "SetMaxSlotCount must be called before Start") + assert(maxSlotCount >= 1, "Bad maxSlotCount") + self._maxSlotCount = maxSlotCount +end + +--[=[ + Sets the slot summary provider +]=] +function SaveSlotService.SetSummaryProvider(self: SaveSlotService, provider: (Player, any) -> string): () + assert(type(provider) == "function", "Bad provider") + self._summaryProvider = provider +end + +--[=[ + Observes the [DataStoreStage] for the player's active slot +]=] +function SaveSlotService.ObserveActiveSlotStoreBrio( + self: SaveSlotService, + player: Player +): Observable.Observable> + return self._hasSaveSlotsBinder:ObserveBrio(player):Pipe({ + RxBrioUtils.switchMapBrio(function(hasSaveSlots) + return Rx.fromPromise(self._playerDataStoreService:PromiseDataStore(player)):Pipe({ + Rx.switchMap(function(dataStore) + return hasSaveSlots.ActiveSlotIndex + :ObserveBrio(function(slotIndex: number?) + return (slotIndex ~= nil) + end) + :Pipe({ + RxBrioUtils.map(function(slotIndex: number) + return dataStore:GetSubStore("saveSlots"):GetSubStore(tostring(slotIndex)) + end), + }) + end) :: any, + }) + end), + }) +end + +--[=[ + Returns the [DataStoreStage] for the player's active slot +]=] +function SaveSlotService.PromiseActiveSlotStore( + self: SaveSlotService, + player: Player +): Promise.Promise + return self._hasSaveSlotsBinder:Promise(player):Then(function(hasSaveSlots) + return hasSaveSlots:PromiseSlotsLoaded():Then(function() + return self._playerDataStoreService:PromiseDataStore(player):Then(function(dataStore) + local slotKey = tostring(hasSaveSlots.ActiveSlotIndex.Value) + return dataStore:GetSubStore("saveSlots"):GetSubStore(slotKey) + end) + end) + end) +end + +--[=[ + Returns whether the player has a slot at the given index +]=] +function SaveSlotService.PromiseHasSlot( + self: SaveSlotService, + player: Player, + slotIndex: number +): Promise.Promise + return self._hasSaveSlotsBinder:Promise(player):Then(function(hasSaveSlots) + return hasSaveSlots:PromiseHasSlot(slotIndex) + end) +end + +--[=[ + Selects the slot at the given index for the player +]=] +function SaveSlotService.PromiseSelectSlot( + self: SaveSlotService, + player: Player, + slotIndex: number +): Promise.Promise + return self._hasSaveSlotsBinder:Promise(player):Then(function(hasSaveSlots) + return hasSaveSlots:PromiseSelectSlot(slotIndex) + end) +end + +--[=[ + Creates a slot for the player at the given index +]=] +function SaveSlotService.PromiseCreateSlot( + self: SaveSlotService, + player: Player, + slotIndex: number, + metadata: SaveSlotData.SaveSlotMetadata? +): Promise.Promise + return self._hasSaveSlotsBinder:Promise(player):Then(function(hasSaveSlots) + return hasSaveSlots:PromiseCreateSlot(slotIndex, metadata) + end) +end + +--[=[ + Deletes the slot at the given index for the player +]=] +function SaveSlotService.PromiseDeleteSlot( + self: SaveSlotService, + player: Player, + slotIndex: number +): Promise.Promise + return self._hasSaveSlotsBinder:Promise(player):Then(function(hasSaveSlots) + return hasSaveSlots:PromiseDeleteSlot(slotIndex) + end) +end + +--[=[ + Refreshes the player's active slot summary +]=] +function SaveSlotService.PromiseRefreshActiveSlotSummary(self: SaveSlotService, player: Player): Promise.Promise + return self._hasSaveSlotsBinder:Promise(player):Then(function(hasSaveSlots) + return hasSaveSlots:PromiseRefreshActiveSlotSummary() + end) +end + +--[=[ + Destroys the service +]=] +function SaveSlotService.Destroy(self: SaveSlotService): () + self._maid:Destroy() +end + +return SaveSlotService diff --git a/src/saveslot/src/Shared/Cmdr/SaveSlotCmdrUtils.lua b/src/saveslot/src/Shared/Cmdr/SaveSlotCmdrUtils.lua new file mode 100644 index 00000000000..d2e8a67815d --- /dev/null +++ b/src/saveslot/src/Shared/Cmdr/SaveSlotCmdrUtils.lua @@ -0,0 +1,33 @@ +--!strict +--[=[ + @class SaveSlotCmdrUtils +]=] + +local SaveSlotCmdrUtils = {} + +function SaveSlotCmdrUtils.registerSlotIndexType(cmdr, saveSlotDataService) + local slotIndex = { + Transform = function(text: string, player: Player) + local slots = saveSlotDataService:GetSlotList(player) + local slotIndices = {} + for _, metadata in slots do + table.insert(slotIndices, tostring(metadata.SlotIndex)) + end + return cmdr.Util.MakeFuzzyFinder(slotIndices)(text) + end, + Validate = function(keys) + return #keys > 0, "No matching slot." + end, + Autocomplete = function(keys) + return keys + end, + Parse = function(keys) + return tonumber(keys[1]) + end, + } + + cmdr.Registry:RegisterType("slotIndex", slotIndex) + cmdr.Registry:RegisterType("slotIndices", cmdr.Util.MakeListableType(slotIndex)) +end + +return SaveSlotCmdrUtils diff --git a/src/saveslot/src/Shared/Data/HasSaveSlotsData.lua b/src/saveslot/src/Shared/Data/HasSaveSlotsData.lua new file mode 100644 index 00000000000..eb3ee92f19f --- /dev/null +++ b/src/saveslot/src/Shared/Data/HasSaveSlotsData.lua @@ -0,0 +1,14 @@ +--!strict +--[=[ + @class HasSaveSlotsData +]=] + +local require = require(script.Parent.loader).load(script) + +local AdorneeData = require("AdorneeData") +local AdorneeDataEntry = require("AdorneeDataEntry") + +return AdorneeData.new({ + ActiveSlotIndex = AdorneeDataEntry.optionalAttribute("number", "ActiveSlotIndex"), + MaxSlotCount = math.huge, +}) diff --git a/src/saveslot/src/Shared/Data/SaveSlotData.lua b/src/saveslot/src/Shared/Data/SaveSlotData.lua new file mode 100644 index 00000000000..f8eb760a19b --- /dev/null +++ b/src/saveslot/src/Shared/Data/SaveSlotData.lua @@ -0,0 +1,25 @@ +--!strict +--[=[ + @class SaveSlotData +]=] + +local require = require(script.Parent.loader).load(script) + +local AdorneeData = require("AdorneeData") +local AdorneeDataEntry = require("AdorneeDataEntry") + +export type SaveSlotMetadata = { + SlotIndex: number, + SlotName: string?, + CreatedTime: number?, + LastPlayedTime: number?, + Summary: string?, +} + +return AdorneeData.new({ + SlotIndex = 0, + SlotName = AdorneeDataEntry.optionalAttribute("string", "SlotName"), + CreatedTime = AdorneeDataEntry.optionalAttribute("number", "CreatedTime"), + LastPlayedTime = AdorneeDataEntry.optionalAttribute("number", "LastPlayedTime"), + Summary = AdorneeDataEntry.optionalAttribute("string", "Summary"), +}) diff --git a/src/saveslot/src/Shared/HasSaveSlotsBase.lua b/src/saveslot/src/Shared/HasSaveSlotsBase.lua new file mode 100644 index 00000000000..13b2a7f0545 --- /dev/null +++ b/src/saveslot/src/Shared/HasSaveSlotsBase.lua @@ -0,0 +1,44 @@ +--!strict +--[=[ + @class HasSaveSlotsBase +]=] + +local require = require(script.Parent.loader).load(script) + +local BaseObject = require("BaseObject") +local HasSaveSlotsData = require("HasSaveSlotsData") +local ServiceBag = require("ServiceBag") +local ValueObject = require("ValueObject") + +local HasSaveSlotsBase = setmetatable({}, BaseObject) +HasSaveSlotsBase.ClassName = "HasSaveSlotsBase" +HasSaveSlotsBase.__index = HasSaveSlotsBase + +export type HasSaveSlotsBase = + typeof(setmetatable( + {} :: { + _obj: Player, + _serviceBag: ServiceBag.ServiceBag, + _attributes: any, + + ActiveSlotIndex: ValueObject.ValueObject, + MaxSlotCount: ValueObject.ValueObject, + }, + {} :: typeof({ __index = HasSaveSlotsBase }) + )) + & BaseObject.BaseObject + +function HasSaveSlotsBase.new(player: Player, serviceBag: ServiceBag.ServiceBag): HasSaveSlotsBase + local self: HasSaveSlotsBase = setmetatable(BaseObject.new(player) :: any, HasSaveSlotsBase) + + self._serviceBag = assert(serviceBag, "No serviceBag") + + self._attributes = HasSaveSlotsData:Create(self._obj) + + self.ActiveSlotIndex = self._attributes.ActiveSlotIndex + self.MaxSlotCount = self._attributes.MaxSlotCount + + return self +end + +return HasSaveSlotsBase diff --git a/src/saveslot/src/Shared/HasSaveSlotsInterface.lua b/src/saveslot/src/Shared/HasSaveSlotsInterface.lua new file mode 100644 index 00000000000..a8428043ed6 --- /dev/null +++ b/src/saveslot/src/Shared/HasSaveSlotsInterface.lua @@ -0,0 +1,22 @@ +--!strict +--[=[ + @class HasSaveSlotsInterface +]=] + +local require = require(script.Parent.loader).load(script) + +local TieDefinition = require("TieDefinition") + +return TieDefinition.new("HasSaveSlots", { + ActiveSlotIndex = TieDefinition.Types.PROPERTY, + MaxSlotCount = TieDefinition.Types.PROPERTY, + + PromiseHasSlot = TieDefinition.Types.METHOD, + PromiseSelectSlot = TieDefinition.Types.METHOD, + PromiseCreateSlot = TieDefinition.Types.METHOD, + PromiseDeleteSlot = TieDefinition.Types.METHOD, + PromiseSetSlotMetadata = TieDefinition.Types.METHOD, + PromiseGetSlotMetadata = TieDefinition.Types.METHOD, + PromiseLastActiveSlotIndex = TieDefinition.Types.METHOD, + PromiseRefreshActiveSlotSummary = TieDefinition.Types.METHOD, +}) diff --git a/src/saveslot/src/Shared/SaveSlotConstants.lua b/src/saveslot/src/Shared/SaveSlotConstants.lua new file mode 100644 index 00000000000..4ce50bfe271 --- /dev/null +++ b/src/saveslot/src/Shared/SaveSlotConstants.lua @@ -0,0 +1,13 @@ +--!strict +--[=[ + @class SaveSlotConstants +]=] + +local require = require(script.Parent.loader).load(script) + +local Table = require("Table") + +return Table.readonly({ + METADATA_CONTAINER_NAME = "SaveSlots", + DEFAULT_SLOT_INDEX = 1, +}) diff --git a/src/saveslot/src/Shared/SaveSlotDataService.lua b/src/saveslot/src/Shared/SaveSlotDataService.lua new file mode 100644 index 00000000000..554662e1cb1 --- /dev/null +++ b/src/saveslot/src/Shared/SaveSlotDataService.lua @@ -0,0 +1,141 @@ +--!strict +--[=[ + @class SaveSlotDataService +]=] + +local require = require(script.Parent.loader).load(script) + +local HasSaveSlotsInterface = require("HasSaveSlotsInterface") +local Observable = require("Observable") +local Rx = require("Rx") +local RxBrioUtils = require("RxBrioUtils") +local RxInstanceUtils = require("RxInstanceUtils") +local SaveSlotConstants = require("SaveSlotConstants") +local SaveSlotData = require("SaveSlotData") +local ServiceBag = require("ServiceBag") +local TieRealmService = require("TieRealmService") +local TieRealms = require("TieRealms") + +local SaveSlotDataService = {} +SaveSlotDataService.ServiceName = "SaveSlotDataService" + +export type SaveSlotDataService = typeof(setmetatable( + {} :: { + _serviceBag: ServiceBag.ServiceBag, + _tieRealmService: TieRealmService.TieRealmService, + _realm: TieRealms.TieRealm, + }, + {} :: typeof({ __index = SaveSlotDataService }) +)) + +function SaveSlotDataService.Init(self: SaveSlotDataService, serviceBag: ServiceBag.ServiceBag) + assert(not (self :: any)._serviceBag, "Already initialized") + self._serviceBag = assert(serviceBag, "No serviceBag") + + -- External + self._tieRealmService = self._serviceBag:GetService(TieRealmService) :: any + + self._realm = self._tieRealmService:GetTieRealm() +end + +--[=[ + Observes the player's active slot index +]=] +function SaveSlotDataService.ObserveActiveSlotIndex( + self: SaveSlotDataService, + player: Player +): Observable.Observable + return (HasSaveSlotsInterface:ObserveBrio(player, self._realm) :: any):Pipe({ + RxBrioUtils.switchMapBrio(function(hasSaveSlots) + return hasSaveSlots.ActiveSlotIndex:Observe() + end), + RxBrioUtils.emitOnDeath(nil), + }) +end + +--[=[ + Returns the player's active slot index +]=] +function SaveSlotDataService.GetActiveSlotIndex(self: SaveSlotDataService, player: Player): number? + local hasSaveSlots = HasSaveSlotsInterface:Find(player, self._realm) + return hasSaveSlots and hasSaveSlots.ActiveSlotIndex.Value +end + +--[=[ + Observes the player's active slot list +]=] +function SaveSlotDataService.ObserveSlotList( + self: SaveSlotDataService, + player: Player +): Observable.Observable<{ SaveSlotData.SaveSlotMetadata }?> + return ( + RxInstanceUtils.observeLastNamedChildBrio(player, "Folder", SaveSlotConstants.METADATA_CONTAINER_NAME) :: any + ):Pipe({ + RxBrioUtils.switchMapBrio(function(slotContainer: Folder) + return RxInstanceUtils.observeChildrenBrio(slotContainer):Pipe({ + RxBrioUtils.flatMapBrio(function(slotFolder) + return SaveSlotData:Observe(slotFolder) + end) :: any, + RxBrioUtils.reduceToAliveList() :: any, + }) + end), + RxBrioUtils.emitOnDeath(nil), + }) +end + +--[=[ + Returns the player's slot list +]=] +function SaveSlotDataService.GetSlotList(self: SaveSlotDataService, player: Player): { SaveSlotData.SaveSlotMetadata } + local slotList = {} + + local slotContainer = player:FindFirstChild(SaveSlotConstants.METADATA_CONTAINER_NAME) + if slotContainer then + for _, slot in slotContainer:GetChildren() do + table.insert(slotList, SaveSlotData:Get(slot)) + end + end + + return slotList +end + +--[=[ + Observes the slot metadata at the given index for the player +]=] +function SaveSlotDataService.ObserveSlotMetadata( + self: SaveSlotDataService, + player: Player, + slotIndex: number +): Observable.Observable + return ( + RxInstanceUtils.observeLastNamedChildBrio(player, "Folder", SaveSlotConstants.METADATA_CONTAINER_NAME) :: any + ):Pipe({ + RxBrioUtils.switchMapBrio(function(slotContainer: Folder) + return RxInstanceUtils.observeLastNamedChildBrio(slotContainer, "Folder", tostring(slotIndex)) + end), + RxBrioUtils.emitOnDeath(nil), + Rx.switchMap(function(slot: Folder?) + return slot and SaveSlotData:Observe(slot) or Rx.EMPTY + end), + }) +end + +--[=[ + Returns the slot metadata at the given index for the player +]=] +function SaveSlotDataService.GetSlotMetadata( + self: SaveSlotDataService, + player: Player, + slotIndex: number +): SaveSlotData.SaveSlotMetadata? + local slotContainer = player:FindFirstChild(SaveSlotConstants.METADATA_CONTAINER_NAME) + local slot = slotContainer and slotContainer:FindFirstChild(tostring(slotIndex)) + + if slot then + return SaveSlotData:Get(slot) + else + return nil + end +end + +return SaveSlotDataService diff --git a/src/saveslot/src/node_modules.project.json b/src/saveslot/src/node_modules.project.json new file mode 100644 index 00000000000..46233dac4ff --- /dev/null +++ b/src/saveslot/src/node_modules.project.json @@ -0,0 +1,7 @@ +{ + "name": "node_modules", + "globIgnorePaths": [ "**/.package-lock.json" ], + "tree": { + "$path": { "optional": "../node_modules" } + } +} \ No newline at end of file diff --git a/src/saveslot/test/default.project.json b/src/saveslot/test/default.project.json new file mode 100644 index 00000000000..19213e5000e --- /dev/null +++ b/src/saveslot/test/default.project.json @@ -0,0 +1,29 @@ +{ + "name": "SaveSlotTest", + "globIgnorePaths": [ + "**/.package-lock.json", + "**/.pnpm", + "**/.pnpm-workspace-state-v1.json", + "**/.modules.yaml", + "**/.ignored", + "**/.ignored_*" + ], + "tree": { + "$className": "DataModel", + "ServerScriptService": { + "saveslot": { + "$path": ".." + }, + "Script": { + "$path": "scripts/Server" + } + }, + "StarterPlayer": { + "StarterPlayerScripts": { + "Main": { + "$path": "scripts/Client" + } + } + } + } +} \ No newline at end of file diff --git a/src/saveslot/test/scripts/Client/ClientMain.client.lua b/src/saveslot/test/scripts/Client/ClientMain.client.lua new file mode 100644 index 00000000000..29b40177d49 --- /dev/null +++ b/src/saveslot/test/scripts/Client/ClientMain.client.lua @@ -0,0 +1,12 @@ +--!nonstrict +--[[ + @class ClientMain +]] + +local loader = game:GetService("ReplicatedStorage"):WaitForChild("saveslot"):WaitForChild("loader") +local require = require(loader).bootstrapGame(loader.Parent) + +local serviceBag = require("ServiceBag").new() +serviceBag:GetService(require("SaveSlotServiceClient")) +serviceBag:Init() +serviceBag:Start() diff --git a/src/saveslot/test/scripts/Server/ServerMain.server.lua b/src/saveslot/test/scripts/Server/ServerMain.server.lua new file mode 100644 index 00000000000..c09e67a3ad8 --- /dev/null +++ b/src/saveslot/test/scripts/Server/ServerMain.server.lua @@ -0,0 +1,14 @@ +--!nonstrict +--[[ + @class ServerMain +]] + +local ServerScriptService = game:GetService("ServerScriptService") + +local loader = ServerScriptService:FindFirstChild("LoaderUtils", true).Parent +local require = require(loader).bootstrapGame(ServerScriptService.saveslot) + +local serviceBag = require("ServiceBag").new() +serviceBag:GetService(require("SaveSlotService")) +serviceBag:Init() +serviceBag:Start() From 9e4e13fe4a30f7b6216ab0fab845af06b6a6c6ff Mon Sep 17 00:00:00 2001 From: Alex Turner Date: Tue, 12 May 2026 12:47:14 -0500 Subject: [PATCH 2/4] chore: Lint --- src/saveslot/src/Shared/SaveSlotDataService.lua | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/saveslot/src/Shared/SaveSlotDataService.lua b/src/saveslot/src/Shared/SaveSlotDataService.lua index 554662e1cb1..870bcce45ba 100644 --- a/src/saveslot/src/Shared/SaveSlotDataService.lua +++ b/src/saveslot/src/Shared/SaveSlotDataService.lua @@ -65,7 +65,7 @@ end Observes the player's active slot list ]=] function SaveSlotDataService.ObserveSlotList( - self: SaveSlotDataService, + _self: SaveSlotDataService, player: Player ): Observable.Observable<{ SaveSlotData.SaveSlotMetadata }?> return ( @@ -86,7 +86,7 @@ end --[=[ Returns the player's slot list ]=] -function SaveSlotDataService.GetSlotList(self: SaveSlotDataService, player: Player): { SaveSlotData.SaveSlotMetadata } +function SaveSlotDataService.GetSlotList(_self: SaveSlotDataService, player: Player): { SaveSlotData.SaveSlotMetadata } local slotList = {} local slotContainer = player:FindFirstChild(SaveSlotConstants.METADATA_CONTAINER_NAME) @@ -103,7 +103,7 @@ end Observes the slot metadata at the given index for the player ]=] function SaveSlotDataService.ObserveSlotMetadata( - self: SaveSlotDataService, + _self: SaveSlotDataService, player: Player, slotIndex: number ): Observable.Observable @@ -124,7 +124,7 @@ end Returns the slot metadata at the given index for the player ]=] function SaveSlotDataService.GetSlotMetadata( - self: SaveSlotDataService, + _self: SaveSlotDataService, player: Player, slotIndex: number ): SaveSlotData.SaveSlotMetadata? From fe93d2ebff43ce6bd813e7a006604325b5c02e62 Mon Sep 17 00:00:00 2001 From: Alex Turner Date: Sun, 24 May 2026 21:18:13 -0500 Subject: [PATCH 3/4] refactor: Switch to GUIDs, default to root store --- pnpm-lock.yaml | 9 + src/saveslot/package.json | 3 + .../src/Client/Binders/HasSaveSlotsClient.lua | 46 ++-- .../src/Server/Binders/HasSaveSlots.lua | 221 +++++++++++++----- .../src/Server/Cmdr/SaveSlotCmdrService.lua | 42 +++- src/saveslot/src/Server/SaveSlotService.lua | 74 ++---- .../src/Shared/Data/HasSaveSlotsData.lua | 2 +- src/saveslot/src/Shared/Data/SaveSlotData.lua | 5 + src/saveslot/src/Shared/HasSaveSlotsBase.lua | 9 +- .../src/Shared/HasSaveSlotsInterface.lua | 12 +- src/saveslot/src/Shared/SaveSlotConstants.lua | 3 + .../src/Shared/SaveSlotDataService.lua | 38 +-- 12 files changed, 305 insertions(+), 159 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4eacac70d94..6c602b9a77c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4322,12 +4322,21 @@ importers: '@quenty/promise': specifier: workspace:* version: link:../promise + '@quenty/propertyvalue': + specifier: workspace:* + version: link:../propertyvalue '@quenty/remoting': specifier: workspace:* version: link:../remoting '@quenty/rx': specifier: workspace:* version: link:../rx + '@quenty/servicebag': + specifier: workspace:* + version: link:../servicebag + '@quenty/signal': + specifier: workspace:* + version: link:../signal '@quenty/table': specifier: workspace:* version: link:../table diff --git a/src/saveslot/package.json b/src/saveslot/package.json index 228ea0b5d1d..fab357b2710 100644 --- a/src/saveslot/package.json +++ b/src/saveslot/package.json @@ -39,8 +39,11 @@ "@quenty/maid": "workspace:*", "@quenty/playerbinder": "workspace:*", "@quenty/promise": "workspace:*", + "@quenty/propertyvalue": "workspace:*", "@quenty/remoting": "workspace:*", "@quenty/rx": "workspace:*", + "@quenty/servicebag": "workspace:*", + "@quenty/signal": "workspace:*", "@quenty/table": "workspace:*", "@quenty/tie": "workspace:*", "@quenty/valueobject": "workspace:*" diff --git a/src/saveslot/src/Client/Binders/HasSaveSlotsClient.lua b/src/saveslot/src/Client/Binders/HasSaveSlotsClient.lua index 9352f84af5e..adeae015a6b 100644 --- a/src/saveslot/src/Client/Binders/HasSaveSlotsClient.lua +++ b/src/saveslot/src/Client/Binders/HasSaveSlotsClient.lua @@ -47,17 +47,17 @@ function HasSaveSlotsClient.new(player: Player, serviceBag: ServiceBag.ServiceBa end --[=[ - Returns whether the slot at the given index exists + Returns whether the slot with the given ID exists ]=] -function HasSaveSlotsClient.PromiseHasSlot(self: HasSaveSlotsClient, slotIndex: number): Promise.Promise - return self._remoting.PromiseHasSlot:PromiseInvokeServer(slotIndex) +function HasSaveSlotsClient.PromiseHasSlot(self: HasSaveSlotsClient, slotId: string?): Promise.Promise + return self._remoting.PromiseHasSlot:PromiseInvokeServer(slotId) end --[=[ - Selects the slot at the given index + Selects the slot with the given ID ]=] -function HasSaveSlotsClient.PromiseSelectSlot(self: HasSaveSlotsClient, slotIndex: number): Promise.Promise - return self._remoting.PromiseSelectSlot:PromiseInvokeServer(slotIndex) +function HasSaveSlotsClient.PromiseSelectSlot(self: HasSaveSlotsClient, slotId: string): Promise.Promise + return self._remoting.PromiseSelectSlot:PromiseInvokeServer(slotId) end --[=[ @@ -72,38 +72,48 @@ function HasSaveSlotsClient.PromiseCreateSlot( end --[=[ - Deletes the slot at the given index + Deletes the slot with the given ID ]=] -function HasSaveSlotsClient.PromiseDeleteSlot(self: HasSaveSlotsClient, slotIndex: number): Promise.Promise - return self._remoting.PromiseDeleteSlot:PromiseInvokeServer(slotIndex) +function HasSaveSlotsClient.PromiseDeleteSlot(self: HasSaveSlotsClient, slotId: string): Promise.Promise + return self._remoting.PromiseDeleteSlot:PromiseInvokeServer(slotId) end --[=[ - Sets the metadata for the slot at the given index + Sets the metadata for the slot with the given ID ]=] function HasSaveSlotsClient.PromiseSetSlotMetadata( self: HasSaveSlotsClient, - slotIndex: number, + slotId: string, data: SaveSlotData.SaveSlotMetadata ): Promise.Promise - return self._remoting.PromiseSetSlotMetadata:PromiseInvokeServer(slotIndex, data) + return self._remoting.PromiseSetSlotMetadata:PromiseInvokeServer(slotId, data) end --[=[ - Gets the metadata for the slot at the given index + Gets the metadata for the slot with the given ID ]=] function HasSaveSlotsClient.PromiseGetSlotMetadata( self: HasSaveSlotsClient, - slotIndex: number + slotId: string ): Promise.Promise - return self._remoting.PromiseGetSlotMetadata:PromiseInvokeServer(slotIndex) + return self._remoting.PromiseGetSlotMetadata:PromiseInvokeServer(slotId) +end + +--[=[ + Gets the last active slot ID +]=] +function HasSaveSlotsClient.PromiseLastActiveSlotId(self: HasSaveSlotsClient): Promise.Promise + return self._remoting.PromiseLastActiveSlotId:PromiseInvokeServer() end --[=[ - Gets the last active slot index + Returns the slot ID from the given index ]=] -function HasSaveSlotsClient.PromiseLastActiveSlotIndex(self: HasSaveSlotsClient): Promise.Promise - return self._remoting.PromiseLastActiveSlotIndex:PromiseInvokeServer() +function HasSaveSlotsClient.PromiseSlotIdFromIndex( + self: HasSaveSlotsClient, + slotIndex: number +): Promise.Promise + return self._remoting.PromiseSlotIdFromIndex:PromiseInvokeServer(slotIndex) end --[=[ diff --git a/src/saveslot/src/Server/Binders/HasSaveSlots.lua b/src/saveslot/src/Server/Binders/HasSaveSlots.lua index 2bae93f274e..c8836dd36f6 100644 --- a/src/saveslot/src/Server/Binders/HasSaveSlots.lua +++ b/src/saveslot/src/Server/Binders/HasSaveSlots.lua @@ -5,7 +5,11 @@ local require = require(script.Parent.loader).load(script) +local HttpService = game:GetService("HttpService") + local Binder = require("Binder") +local Brio = require("Brio") +local DataStoreStage = require("DataStoreStage") local HasSaveSlotsBase = require("HasSaveSlotsBase") local HasSaveSlotsInterface = require("HasSaveSlotsInterface") local Maid = require("Maid") @@ -13,11 +17,13 @@ local PlayerBinder = require("PlayerBinder") local PlayerDataStoreService = require("PlayerDataStoreService") local Promise = require("Promise") local Remoting = require("Remoting") +local Rx = require("Rx") +local RxBrioUtils = require("RxBrioUtils") local SaveSlotConstants = require("SaveSlotConstants") local SaveSlotData = require("SaveSlotData") local ServiceBag = require("ServiceBag") -type SaveSlot = { +type SaveSlotStruct = { folder: Folder, attributes: any, maid: Maid.Maid, @@ -34,13 +40,14 @@ export type HasSaveSlots = _serviceBag: ServiceBag.ServiceBag, _playerDataStoreService: any, _slotContainer: Folder, - _slotMap: { [number]: SaveSlot }, + _slotMap: { [string]: SaveSlotStruct }, _loadPromise: Promise.Promise<{}>, _remoting: any, _dataStore: any, + _systemStore: any, _metadataStore: any, _summaryProvider: ((Player, any) -> string)?, - _lastActiveSlotIndex: number?, + _lastActiveSlotId: string?, }, {} :: typeof({ __index = HasSaveSlots }) )) @@ -70,6 +77,38 @@ function HasSaveSlots.new(player: Player, serviceBag: ServiceBag.ServiceBag): Ha return self end +--[=[ + Observes the [DataStoreStage] for the active slot as a [Brio] +]=] +function HasSaveSlots.ObserveActiveSlotStoreBrio(self: HasSaveSlots): Brio.Brio + return Rx.fromPromise(self._loadPromise):Pipe({ + Rx.switchMap(function() + return self.ActiveSlotId + :ObserveBrio(function(slotId: string?) + return (slotId ~= nil) + end) + :Pipe({ + RxBrioUtils.map(function(slotId: string) + return self:_getSlotStore(slotId) + end) :: any, + }) :: any + end) :: any, + }) :: any +end + +--[=[ + Returns the [DataStoreStage] for the active slot +]=] +function HasSaveSlots.PromiseActiveSlotStore(self: HasSaveSlots): Promise.Promise + return (self._loadPromise :: any):Then(function() + print(self.ActiveSlotId.Value) + if not self.ActiveSlotId.Value then + return (Promise :: any).resolved(nil) + end + return self:_getSlotStore(self.ActiveSlotId.Value) + end) +end + --[=[ Promises that all slots have loaded ]=] @@ -78,35 +117,35 @@ function HasSaveSlots.PromiseSlotsLoaded(self: HasSaveSlots): Promise.Promise +function HasSaveSlots.PromiseHasSlot(self: HasSaveSlots, slotId: string?): Promise.Promise return (self._loadPromise :: any):Then(function() - return (self._slotMap[slotIndex] ~= nil) + return slotId and (self._slotMap[slotId] ~= nil) end) end --[=[ - Selects the slot at the given index + Selects the slot with the given ID ]=] -function HasSaveSlots.PromiseSelectSlot(self: HasSaveSlots, slotIndex: number): Promise.Promise +function HasSaveSlots.PromiseSelectSlot(self: HasSaveSlots, slotId: string): Promise.Promise return (self._loadPromise :: any):Then(function() - if slotIndex == self.ActiveSlotIndex.Value then + if slotId == self.ActiveSlotId.Value then return -- Already set end - local slot = self._slotMap[slotIndex] + local slot = self._slotMap[slotId] if not slot then - return (Promise :: any).rejected(`Slot {slotIndex} not found`) + return (Promise :: any).rejected(`Slot \{{slotId}\} not found`) end local function setSlot() - self.ActiveSlotIndex.Value = slotIndex + self.ActiveSlotId.Value = slotId slot.attributes.LastPlayedTime.Value = os.time() end -- Initialize or save and switch - if self.ActiveSlotIndex.Value == nil then + if self.ActiveSlotId.Value == nil then setSlot() return end @@ -122,84 +161,123 @@ function HasSaveSlots.PromiseCreateSlot( self: HasSaveSlots, slotIndex: number, metadata: SaveSlotData.SaveSlotMetadata? -): Promise.Promise +): Promise.Promise return (self._loadPromise :: any):Then(function() - if self._slotMap[slotIndex] then - return (Promise :: any).rejected(`Slot {slotIndex} already exists`) + if (slotIndex < 1) or (slotIndex > self.MaxSlotCount.Value) then + return (Promise :: any).rejected(`Index must be in range [1, {self.MaxSlotCount.Value}]`) end - if slotIndex > self.MaxSlotCount.Value then - return (Promise :: any).rejected(`Index {slotIndex} exceeds max of {self.MaxSlotCount.Value}`) + for _, slot in self._slotMap do + if slotIndex == slot.attributes.SlotIndex.Value then + return (Promise :: any).rejected(`Slot {slotIndex} already exists`) + end end + local slotId = HttpService:GenerateGUID(false) local data = { + SlotId = slotId, SlotIndex = slotIndex, SlotName = (metadata and metadata.SlotName) or `Slot {slotIndex}`, CreatedTime = os.time(), Summary = metadata and metadata.Summary, } - self:_buildSlot(slotIndex, data, true) + self:_buildSlot(slotId, data, true) + return slotId end) end --[=[ - Deletes the slot at the given index + Deletes the slot with the given ID ]=] -function HasSaveSlots.PromiseDeleteSlot(self: HasSaveSlots, slotIndex: number): Promise.Promise +function HasSaveSlots.PromiseDeleteSlot(self: HasSaveSlots, slotId: string): Promise.Promise return (self._loadPromise :: any):Then(function() - if slotIndex == self.ActiveSlotIndex.Value then + if slotId == self.ActiveSlotId.Value then return (Promise :: any).rejected("Cannot delete active slot") end - local slot = self._slotMap[slotIndex] + local slot = self._slotMap[slotId] if not slot then - return (Promise :: any).rejected(`Slot {slotIndex} not found`) + return (Promise :: any).rejected(`Slot \{{slotId}\} not found`) end slot.maid:Destroy() - local slotKey = tostring(slotIndex) - self._metadataStore:Delete(slotKey) - self._dataStore:GetSubStore("saveSlots"):Delete(slotKey) + -- Wipe default slot + if slot.attributes.SlotIndex.Value == SaveSlotConstants.DEFAULT_SLOT_INDEX then + return self._dataStore:PromiseKeyList():Then(function(keys) + for _, key in keys do + if key ~= SaveSlotConstants.INTERNAL_STORE_KEY then + self._dataStore:Delete(key) + end + end + self._metadataStore:Delete(slotId) + end) + end + + -- Or delete slot from substore + self._systemStore:GetSubStore(SaveSlotConstants.SLOT_STORE_KEY):Delete(slotId) + self._metadataStore:Delete(slotId) end) end --[=[ - Sets the metadata for the slot at the given index + Sets the metadata for the slot with the given ID ]=] function HasSaveSlots.PromiseSetSlotMetadata( self: HasSaveSlots, - slotIndex: number, + slotId: string, data: SaveSlotData.SaveSlotMetadata ): Promise.Promise - assert(data.SlotIndex == nil or data.SlotIndex == slotIndex, "SlotIndex is locked") + if data.SlotId and (data.SlotId ~= slotId) then + return (Promise :: any).rejected("SlotId is locked") + end return (self._loadPromise :: any):Then(function() - local slot = self._slotMap[slotIndex] + local slot = self._slotMap[slotId] + + -- Routing depends on immutable indices to distinguish the default slot + if data.SlotIndex and (data.SlotIndex ~= slot.attributes.SlotIndex.Value) then + return (Promise :: any).rejected("SlotIndex is locked") + end + SaveSlotData:Set(slot.folder, data) end) end --[=[ - Gets the metadata for the slot at the given index + Gets the metadata for the slot with the given ID ]=] function HasSaveSlots.PromiseGetSlotMetadata( self: HasSaveSlots, - slotIndex: number + slotId: string ): Promise.Promise return (self._loadPromise :: any):Then(function() - local slot = self._slotMap[slotIndex] + local slot = self._slotMap[slotId] return (Promise :: any).resolved(slot and slot.attributes) end) end --[=[ - Gets the last active slot index + Returns the slot ID from the given index ]=] -function HasSaveSlots.PromiseLastActiveSlotIndex(self: HasSaveSlots): Promise.Promise +function HasSaveSlots.PromiseSlotIdFromIndex(self: HasSaveSlots, slotIndex: number): Promise.Promise return (self._loadPromise :: any):Then(function() - return self.ActiveSlotIndex.Value or self._lastActiveSlotIndex + for _, slot in self._slotMap do + if slotIndex == slot.attributes.SlotIndex.Value then + return (Promise :: any).resolved(slot.attributes.SlotId.Value) + end + end + return (Promise :: any).resolved(nil) + end) +end + +--[=[ + Gets the last active slot ID +]=] +function HasSaveSlots.PromiseLastActiveSlotId(self: HasSaveSlots): Promise.Promise + return (self._loadPromise :: any):Then(function() + return self.ActiveSlotId.Value or self._lastActiveSlotId end) end @@ -220,66 +298,79 @@ function HasSaveSlots.PromiseRefreshActiveSlotSummary(self: HasSaveSlots): Promi end function HasSaveSlots._promiseLoadSlots(self: HasSaveSlots): Promise.Promise<{}> - return self._maid:GivePromise(self._playerDataStoreService:PromiseDataStore(self._obj)):Then(function(dataStore) + return self._playerDataStoreService:PromiseDataStore(self._obj):Then(function(dataStore) self._dataStore = dataStore - self._metadataStore = dataStore:GetSubStore("saveSlotMetadata") + self._systemStore = dataStore:GetSubStore(SaveSlotConstants.INTERNAL_STORE_KEY) + self._metadataStore = self._systemStore:GetSubStore(SaveSlotConstants.METADATA_STORE_KEY) self._maid:GiveTask(self._dataStore:AddSavingCallback(function() self:_refreshActiveSlotSummary() end)) return self._metadataStore:LoadAll({}):Then(function(metadata) - for key, data in metadata do - local slotIndex = tonumber(key) - if slotIndex then - self:_buildSlot(slotIndex, data) - end + for slotId, data in metadata do + self:_buildSlot(slotId, data) end - return dataStore:Load("ActiveSlotIndex"):Then(function(activeIndex: number?) - self._lastActiveSlotIndex = activeIndex - self._maid:GiveTask(dataStore:StoreOnValueChange("ActiveSlotIndex", self.ActiveSlotIndex)) + return self._systemStore:Load("activeSlotId"):Then(function(activeId: string?) + self._lastActiveSlotId = activeId + self._maid:GiveTask(self._systemStore:StoreOnValueChange("activeSlotId", self.ActiveSlotId)) end) end) end) end +function HasSaveSlots._getSlotStore(self: HasSaveSlots, slotId: string): DataStoreStage.DataStoreStage + local slot = self._slotMap[slotId] + if slot and (slot.attributes.SlotIndex.Value == SaveSlotConstants.DEFAULT_SLOT_INDEX) then + return self._dataStore + end + return self._systemStore:GetSubStore(SaveSlotConstants.SLOT_STORE_KEY):GetSubStore(slotId) +end + function HasSaveSlots._buildSlot( self: HasSaveSlots, - slotIndex: number, + slotId: string, data: SaveSlotData.SaveSlotMetadata, isNew: boolean? ): () local maid = self._maid:Add(Maid.new()) local folder = maid:Add(Instance.new("Folder")) - folder.Name = tostring(slotIndex) + folder.Name = slotId folder.Archivable = false + local metadataStore = self._metadataStore:GetSubStore(slotId) + local attributes = SaveSlotData:Create(folder) - attributes.SlotIndex.Value = slotIndex + attributes.SlotId.Value = slotId + attributes.SlotIndex.Value = data.SlotIndex - local slotStore = self._metadataStore:GetSubStore(tostring(slotIndex)) + -- Store immutable SlotIndex once on creation + if isNew then + metadataStore:Store("SlotIndex", data.SlotIndex) + end + -- Store mutable metadata on change for _, key in { "SlotName", "CreatedTime", "LastPlayedTime", "Summary" } do attributes[key].Value = data[key] - maid:GiveTask(slotStore:StoreOnValueChange(key, attributes[key])) + maid:GiveTask(metadataStore:StoreOnValueChange(key, attributes[key])) if isNew then - slotStore:Store(key, attributes[key].Value) + metadataStore:Store(key, attributes[key].Value) end end folder.Parent = self._slotContainer - self._slotMap[slotIndex] = { + self._slotMap[slotId] = { folder = folder, attributes = attributes, maid = maid, } maid:GiveTask(function() - self._slotMap[slotIndex] = nil + self._slotMap[slotId] = nil end) end @@ -288,13 +379,13 @@ function HasSaveSlots._refreshActiveSlotSummary(self: HasSaveSlots): () return -- No summary provider end - local activeSlotIndex = self.ActiveSlotIndex.Value - local activeSlot = activeSlotIndex and self._slotMap[activeSlotIndex] + local activeSlotId = self.ActiveSlotId.Value + local activeSlot = activeSlotId and self._slotMap[activeSlotId] if not activeSlot then return -- No active slot end - local slotStore = self._dataStore:GetSubStore("saveSlots"):GetSubStore(tostring(activeSlotIndex)) + local slotStore = self:_getSlotStore(activeSlotId :: string) local success, result = pcall(self._summaryProvider, self._obj, slotStore) if not success then @@ -310,7 +401,7 @@ function HasSaveSlots._refreshActiveSlotSummary(self: HasSaveSlots): () activeSlot.attributes.Summary.Value = result -- Store directly to avoid deferral during save callback - self._metadataStore:GetSubStore(tostring(activeSlotIndex)):Store("Summary", result) + self._metadataStore:GetSubStore(activeSlotId):Store("Summary", result) end function HasSaveSlots._setupRemotes(self: HasSaveSlots): () @@ -362,9 +453,17 @@ function HasSaveSlots._setupRemotes(self: HasSaveSlots): () end end)) - self._maid:GiveTask(self._remoting.PromiseLastActiveSlotIndex:Bind(function(remotePlayer) + self._maid:GiveTask(self._remoting.PromiseSlotIdFromIndex:Bind(function(remotePlayer, ...) + if remotePlayer == self._obj then + return self:PromiseSlotIdFromIndex(...) + else + return (Promise :: any).rejected("Bad player") + end + end)) + + self._maid:GiveTask(self._remoting.PromiseLastActiveSlotId:Bind(function(remotePlayer) if remotePlayer == self._obj then - return self:PromiseLastActiveSlotIndex() + return self:PromiseLastActiveSlotId() else return (Promise :: any).rejected("Bad player") end diff --git a/src/saveslot/src/Server/Cmdr/SaveSlotCmdrService.lua b/src/saveslot/src/Server/Cmdr/SaveSlotCmdrService.lua index ef08a33ae74..85baf5b5f5a 100644 --- a/src/saveslot/src/Server/Cmdr/SaveSlotCmdrService.lua +++ b/src/saveslot/src/Server/Cmdr/SaveSlotCmdrService.lua @@ -53,11 +53,12 @@ function SaveSlotCmdrService._registerCommands(self: SaveSlotCmdrService): () Group = "SaveSlots", Args = {}, }, function(context) + local activeSlotId = self._saveSlotDataService:GetActiveSlotId(context.Executor) local slotList = self._saveSlotDataService:GetSlotList(context.Executor) local listString = "" for _, slot in slotList do - local isActive = (slot.SlotIndex == self._saveSlotDataService:GetActiveSlotIndex(context.Executor)) + local isActive = (slot.SlotId == activeSlotId) listString ..= `\n"{slot.SlotName}" ({slot.SlotIndex}){isActive and " — Active" or ""}\n{slot.Summary}\n` end @@ -70,10 +71,14 @@ function SaveSlotCmdrService._registerCommands(self: SaveSlotCmdrService): () Group = "SaveSlots", Args = {}, }, function(context) - local slotIndex = self._saveSlotDataService:GetActiveSlotIndex(context.Executor) - local slotData = self._saveSlotDataService:GetSlotMetadata(context.Executor, slotIndex) + local activeSlotId = self._saveSlotDataService:GetActiveSlotId(context.Executor) + if not activeSlotId then + return "No active slot." + end + + local slotData = self._saveSlotDataService:GetSlotMetadata(context.Executor, activeSlotId) - return `Currently using slot {slotIndex} ("{slotData.SlotName}").` + return `Currently using slot {slotData.SlotIndex} ("{slotData.SlotName}").` end) self._cmdrService:RegisterCommand({ @@ -88,10 +93,15 @@ function SaveSlotCmdrService._registerCommands(self: SaveSlotCmdrService): () }, }, }, function(context, slotIndex: number) + local slotId = self._saveSlotDataService:GetSlotIdFromIndex(context.Executor, slotIndex) + if not slotId then + return `No slot with index {slotIndex}.` + end + self._maid :GivePromise(self._hasSaveSlotsBinder:Promise(context.Executor)) :Then(function(hasSaveSlots) - return hasSaveSlots:PromiseSelectSlot(slotIndex) + return hasSaveSlots:PromiseSelectSlot(slotId) end) :Wait() @@ -115,14 +125,17 @@ function SaveSlotCmdrService._registerCommands(self: SaveSlotCmdrService): () return `Index must be in range [1, {maxSlotCount}].` end - local hasSaveSlots = self._maid:GivePromise(self._hasSaveSlotsBinder:Promise(context.Executor)):Wait() - - local hasSlot = self._maid:GivePromise(hasSaveSlots:PromiseHasSlot(slotIndex)):Wait() - if hasSlot then + local slotId = self._saveSlotDataService:GetSlotIdFromIndex(context.Executor, slotIndex) + if slotId then return "Slot already exists." end - self._maid:GivePromise(hasSaveSlots:PromiseCreateSlot(slotIndex)):Wait() + self._maid + :GivePromise(self._hasSaveSlotsBinder:Promise(context.Executor)) + :Then(function(hasSaveSlots) + return hasSaveSlots:PromiseCreateSlot(slotIndex) + end) + :Wait() return `Created slot {slotIndex}.` end) @@ -139,14 +152,19 @@ function SaveSlotCmdrService._registerCommands(self: SaveSlotCmdrService): () }, }, }, function(context, slotIndex: number) - if slotIndex == self._saveSlotDataService:GetActiveSlotIndex(context.Executor) then + local slotId = self._saveSlotDataService:GetSlotIdFromIndex(context.Executor, slotIndex) + if not slotId then + return `No slot with index {slotIndex}.` + end + + if slotId == self._saveSlotDataService:GetActiveSlotId(context.Executor) then return "Cannot delete active slot." end self._maid :GivePromise(self._hasSaveSlotsBinder:Promise(context.Executor)) :Then(function(hasSaveSlots) - return hasSaveSlots:PromiseDeleteSlot(slotIndex) + return hasSaveSlots:PromiseDeleteSlot(slotId) end) :Wait() diff --git a/src/saveslot/src/Server/SaveSlotService.lua b/src/saveslot/src/Server/SaveSlotService.lua index ff500ec447e..dcf61b07dda 100644 --- a/src/saveslot/src/Server/SaveSlotService.lua +++ b/src/saveslot/src/Server/SaveSlotService.lua @@ -12,10 +12,8 @@ local DataStoreStage = require("DataStoreStage") local HasSaveSlots = require("HasSaveSlots") local Maid = require("Maid") local Observable = require("Observable") -local PlayerDataStoreService = require("PlayerDataStoreService") local Promise = require("Promise") local Remoting = require("Remoting") -local Rx = require("Rx") local RxBrioUtils = require("RxBrioUtils") local SaveSlotConstants = require("SaveSlotConstants") local SaveSlotData = require("SaveSlotData") @@ -28,7 +26,6 @@ export type SaveSlotService = typeof(setmetatable( {} :: { _serviceBag: ServiceBag.ServiceBag, _maid: Maid.Maid, - _playerDataStoreService: any, _hasSaveSlotsBinder: any, _selectionRequired: boolean, _maxSlotCount: number, @@ -44,7 +41,7 @@ function SaveSlotService.Init(self: SaveSlotService, serviceBag: ServiceBag.Serv self._maid = Maid.new() -- External - self._playerDataStoreService = self._serviceBag:GetService(PlayerDataStoreService) + self._serviceBag:GetService(require("PlayerDataStoreService")) -- Internal self._serviceBag:GetService(require("SaveSlotCmdrService")) @@ -83,22 +80,24 @@ function SaveSlotService.Start(self: SaveSlotService) end -- Select last active slot - return hasSaveSlots:PromiseLastActiveSlotIndex():Then(function(lastActiveSlotIndex: number?) - return hasSaveSlots:PromiseHasSlot(lastActiveSlotIndex):Then(function(hasLastSlot: boolean) + return hasSaveSlots:PromiseLastActiveSlotId():Then(function(lastActiveSlotId: string?) + return hasSaveSlots:PromiseHasSlot(lastActiveSlotId):Then(function(hasLastSlot: boolean) if hasLastSlot then - return hasSaveSlots:PromiseSelectSlot(lastActiveSlotIndex) + return hasSaveSlots:PromiseSelectSlot(lastActiveSlotId) end -- Or create and select default slot return hasSaveSlots - :PromiseHasSlot(SaveSlotConstants.DEFAULT_SLOT_INDEX) - :Then(function(hasDefaultSlot: boolean) - if not hasDefaultSlot then + :PromiseSlotIdFromIndex(SaveSlotConstants.DEFAULT_SLOT_INDEX) + :Then(function(defaultSlotId: string?) + if defaultSlotId then + return defaultSlotId + else return hasSaveSlots:PromiseCreateSlot(SaveSlotConstants.DEFAULT_SLOT_INDEX) end end) - :Then(function() - return hasSaveSlots:PromiseSelectSlot(SaveSlotConstants.DEFAULT_SLOT_INDEX) + :Then(function(slotId: string) + return hasSaveSlots:PromiseSelectSlot(slotId) end) end) end) @@ -139,7 +138,7 @@ function SaveSlotService.SetSummaryProvider(self: SaveSlotService, provider: (Pl end --[=[ - Observes the [DataStoreStage] for the player's active slot + Observes the [DataStoreStage] for the player's active slot as a [Brio] ]=] function SaveSlotService.ObserveActiveSlotStoreBrio( self: SaveSlotService, @@ -147,19 +146,7 @@ function SaveSlotService.ObserveActiveSlotStoreBrio( ): Observable.Observable> return self._hasSaveSlotsBinder:ObserveBrio(player):Pipe({ RxBrioUtils.switchMapBrio(function(hasSaveSlots) - return Rx.fromPromise(self._playerDataStoreService:PromiseDataStore(player)):Pipe({ - Rx.switchMap(function(dataStore) - return hasSaveSlots.ActiveSlotIndex - :ObserveBrio(function(slotIndex: number?) - return (slotIndex ~= nil) - end) - :Pipe({ - RxBrioUtils.map(function(slotIndex: number) - return dataStore:GetSubStore("saveSlots"):GetSubStore(tostring(slotIndex)) - end), - }) - end) :: any, - }) + return hasSaveSlots:ObserveActiveSlotStoreBrio() end), }) end @@ -170,40 +157,31 @@ end function SaveSlotService.PromiseActiveSlotStore( self: SaveSlotService, player: Player -): Promise.Promise +): Promise.Promise return self._hasSaveSlotsBinder:Promise(player):Then(function(hasSaveSlots) - return hasSaveSlots:PromiseSlotsLoaded():Then(function() - return self._playerDataStoreService:PromiseDataStore(player):Then(function(dataStore) - local slotKey = tostring(hasSaveSlots.ActiveSlotIndex.Value) - return dataStore:GetSubStore("saveSlots"):GetSubStore(slotKey) - end) - end) + return hasSaveSlots:PromiseActiveSlotStore() end) end --[=[ - Returns whether the player has a slot at the given index + Returns whether the player has a slot with the given ID ]=] -function SaveSlotService.PromiseHasSlot( - self: SaveSlotService, - player: Player, - slotIndex: number -): Promise.Promise +function SaveSlotService.PromiseHasSlot(self: SaveSlotService, player: Player, slotId: string): Promise.Promise return self._hasSaveSlotsBinder:Promise(player):Then(function(hasSaveSlots) - return hasSaveSlots:PromiseHasSlot(slotIndex) + return hasSaveSlots:PromiseHasSlot(slotId) end) end --[=[ - Selects the slot at the given index for the player + Selects the slot with the given ID for the player ]=] function SaveSlotService.PromiseSelectSlot( self: SaveSlotService, player: Player, - slotIndex: number + slotId: string ): Promise.Promise return self._hasSaveSlotsBinder:Promise(player):Then(function(hasSaveSlots) - return hasSaveSlots:PromiseSelectSlot(slotIndex) + return hasSaveSlots:PromiseSelectSlot(slotId) end) end @@ -222,15 +200,11 @@ function SaveSlotService.PromiseCreateSlot( end --[=[ - Deletes the slot at the given index for the player + Deletes the slot with the given ID for the player ]=] -function SaveSlotService.PromiseDeleteSlot( - self: SaveSlotService, - player: Player, - slotIndex: number -): Promise.Promise +function SaveSlotService.PromiseDeleteSlot(self: SaveSlotService, player: Player, slotId: string): Promise.Promise return self._hasSaveSlotsBinder:Promise(player):Then(function(hasSaveSlots) - return hasSaveSlots:PromiseDeleteSlot(slotIndex) + return hasSaveSlots:PromiseDeleteSlot(slotId) end) end diff --git a/src/saveslot/src/Shared/Data/HasSaveSlotsData.lua b/src/saveslot/src/Shared/Data/HasSaveSlotsData.lua index eb3ee92f19f..b3679df19dc 100644 --- a/src/saveslot/src/Shared/Data/HasSaveSlotsData.lua +++ b/src/saveslot/src/Shared/Data/HasSaveSlotsData.lua @@ -9,6 +9,6 @@ local AdorneeData = require("AdorneeData") local AdorneeDataEntry = require("AdorneeDataEntry") return AdorneeData.new({ - ActiveSlotIndex = AdorneeDataEntry.optionalAttribute("number", "ActiveSlotIndex"), + ActiveSlotId = AdorneeDataEntry.optionalAttribute("string", "ActiveSlotId"), MaxSlotCount = math.huge, }) diff --git a/src/saveslot/src/Shared/Data/SaveSlotData.lua b/src/saveslot/src/Shared/Data/SaveSlotData.lua index f8eb760a19b..76becdeaea3 100644 --- a/src/saveslot/src/Shared/Data/SaveSlotData.lua +++ b/src/saveslot/src/Shared/Data/SaveSlotData.lua @@ -7,8 +7,10 @@ local require = require(script.Parent.loader).load(script) local AdorneeData = require("AdorneeData") local AdorneeDataEntry = require("AdorneeDataEntry") +local PropertyValue = require("PropertyValue") export type SaveSlotMetadata = { + SlotId: string, SlotIndex: number, SlotName: string?, CreatedTime: number?, @@ -17,6 +19,9 @@ export type SaveSlotMetadata = { } return AdorneeData.new({ + SlotId = AdorneeDataEntry.new("string", function(folder: Folder) + return PropertyValue.new(folder, "Name") + end), SlotIndex = 0, SlotName = AdorneeDataEntry.optionalAttribute("string", "SlotName"), CreatedTime = AdorneeDataEntry.optionalAttribute("number", "CreatedTime"), diff --git a/src/saveslot/src/Shared/HasSaveSlotsBase.lua b/src/saveslot/src/Shared/HasSaveSlotsBase.lua index 13b2a7f0545..d940421f081 100644 --- a/src/saveslot/src/Shared/HasSaveSlotsBase.lua +++ b/src/saveslot/src/Shared/HasSaveSlotsBase.lua @@ -8,6 +8,7 @@ local require = require(script.Parent.loader).load(script) local BaseObject = require("BaseObject") local HasSaveSlotsData = require("HasSaveSlotsData") local ServiceBag = require("ServiceBag") +local Signal = require("Signal") local ValueObject = require("ValueObject") local HasSaveSlotsBase = setmetatable({}, BaseObject) @@ -21,8 +22,10 @@ export type HasSaveSlotsBase = _serviceBag: ServiceBag.ServiceBag, _attributes: any, - ActiveSlotIndex: ValueObject.ValueObject, + ActiveSlotId: ValueObject.ValueObject, MaxSlotCount: ValueObject.ValueObject, + + SlotChanged: Signal.Signal, }, {} :: typeof({ __index = HasSaveSlotsBase }) )) @@ -35,9 +38,11 @@ function HasSaveSlotsBase.new(player: Player, serviceBag: ServiceBag.ServiceBag) self._attributes = HasSaveSlotsData:Create(self._obj) - self.ActiveSlotIndex = self._attributes.ActiveSlotIndex + self.ActiveSlotId = self._attributes.ActiveSlotId self.MaxSlotCount = self._attributes.MaxSlotCount + self.SlotChanged = self.ActiveSlotId.Changed + return self end diff --git a/src/saveslot/src/Shared/HasSaveSlotsInterface.lua b/src/saveslot/src/Shared/HasSaveSlotsInterface.lua index a8428043ed6..b1b0ad0cb90 100644 --- a/src/saveslot/src/Shared/HasSaveSlotsInterface.lua +++ b/src/saveslot/src/Shared/HasSaveSlotsInterface.lua @@ -8,7 +8,7 @@ local require = require(script.Parent.loader).load(script) local TieDefinition = require("TieDefinition") return TieDefinition.new("HasSaveSlots", { - ActiveSlotIndex = TieDefinition.Types.PROPERTY, + ActiveSlotId = TieDefinition.Types.PROPERTY, MaxSlotCount = TieDefinition.Types.PROPERTY, PromiseHasSlot = TieDefinition.Types.METHOD, @@ -17,6 +17,14 @@ return TieDefinition.new("HasSaveSlots", { PromiseDeleteSlot = TieDefinition.Types.METHOD, PromiseSetSlotMetadata = TieDefinition.Types.METHOD, PromiseGetSlotMetadata = TieDefinition.Types.METHOD, - PromiseLastActiveSlotIndex = TieDefinition.Types.METHOD, + PromiseSlotIdFromIndex = TieDefinition.Types.METHOD, + PromiseLastActiveSlotId = TieDefinition.Types.METHOD, PromiseRefreshActiveSlotSummary = TieDefinition.Types.METHOD, + + SlotChanged = TieDefinition.Types.SIGNAL, + + [TieDefinition.Realms.SERVER] = { + ObserveActiveSlotStoreBrio = TieDefinition.Types.METHOD, + PromiseActiveSlotStore = TieDefinition.Types.METHOD, + }, }) diff --git a/src/saveslot/src/Shared/SaveSlotConstants.lua b/src/saveslot/src/Shared/SaveSlotConstants.lua index 4ce50bfe271..94735889c74 100644 --- a/src/saveslot/src/Shared/SaveSlotConstants.lua +++ b/src/saveslot/src/Shared/SaveSlotConstants.lua @@ -8,6 +8,9 @@ local require = require(script.Parent.loader).load(script) local Table = require("Table") return Table.readonly({ + INTERNAL_STORE_KEY = "SaveSlotInternal", + SLOT_STORE_KEY = "slots", + METADATA_STORE_KEY = "slotMetadata", METADATA_CONTAINER_NAME = "SaveSlots", DEFAULT_SLOT_INDEX = 1, }) diff --git a/src/saveslot/src/Shared/SaveSlotDataService.lua b/src/saveslot/src/Shared/SaveSlotDataService.lua index 870bcce45ba..1adcff8db23 100644 --- a/src/saveslot/src/Shared/SaveSlotDataService.lua +++ b/src/saveslot/src/Shared/SaveSlotDataService.lua @@ -39,26 +39,26 @@ function SaveSlotDataService.Init(self: SaveSlotDataService, serviceBag: Service end --[=[ - Observes the player's active slot index + Observes the player's active slot ID ]=] -function SaveSlotDataService.ObserveActiveSlotIndex( +function SaveSlotDataService.ObserveActiveSlotId( self: SaveSlotDataService, player: Player -): Observable.Observable +): Observable.Observable return (HasSaveSlotsInterface:ObserveBrio(player, self._realm) :: any):Pipe({ RxBrioUtils.switchMapBrio(function(hasSaveSlots) - return hasSaveSlots.ActiveSlotIndex:Observe() + return hasSaveSlots.ActiveSlotId:Observe() end), RxBrioUtils.emitOnDeath(nil), }) end --[=[ - Returns the player's active slot index + Returns the player's active slot ID ]=] -function SaveSlotDataService.GetActiveSlotIndex(self: SaveSlotDataService, player: Player): number? +function SaveSlotDataService.GetActiveSlotId(self: SaveSlotDataService, player: Player): string? local hasSaveSlots = HasSaveSlotsInterface:Find(player, self._realm) - return hasSaveSlots and hasSaveSlots.ActiveSlotIndex.Value + return hasSaveSlots and hasSaveSlots.ActiveSlotId.Value end --[=[ @@ -100,18 +100,18 @@ function SaveSlotDataService.GetSlotList(_self: SaveSlotDataService, player: Pla end --[=[ - Observes the slot metadata at the given index for the player + Observes the slot metadata with the given ID for the player ]=] function SaveSlotDataService.ObserveSlotMetadata( _self: SaveSlotDataService, player: Player, - slotIndex: number + slotId: string ): Observable.Observable return ( RxInstanceUtils.observeLastNamedChildBrio(player, "Folder", SaveSlotConstants.METADATA_CONTAINER_NAME) :: any ):Pipe({ RxBrioUtils.switchMapBrio(function(slotContainer: Folder) - return RxInstanceUtils.observeLastNamedChildBrio(slotContainer, "Folder", tostring(slotIndex)) + return RxInstanceUtils.observeLastNamedChildBrio(slotContainer, "Folder", slotId) end), RxBrioUtils.emitOnDeath(nil), Rx.switchMap(function(slot: Folder?) @@ -121,15 +121,15 @@ function SaveSlotDataService.ObserveSlotMetadata( end --[=[ - Returns the slot metadata at the given index for the player + Returns the slot metadata with the given ID for the player ]=] function SaveSlotDataService.GetSlotMetadata( _self: SaveSlotDataService, player: Player, - slotIndex: number + slotId: string ): SaveSlotData.SaveSlotMetadata? local slotContainer = player:FindFirstChild(SaveSlotConstants.METADATA_CONTAINER_NAME) - local slot = slotContainer and slotContainer:FindFirstChild(tostring(slotIndex)) + local slot = slotContainer and slotContainer:FindFirstChild(slotId) if slot then return SaveSlotData:Get(slot) @@ -138,4 +138,16 @@ function SaveSlotDataService.GetSlotMetadata( end end +--[=[ + Returns the ID for the slot at the given index +]=] +function SaveSlotDataService.GetSlotIdFromIndex(self: SaveSlotDataService, player: Player, slotIndex: number): string? + for _, slot in self:GetSlotList(player) do + if slotIndex == slot.SlotIndex then + return slot.SlotId + end + end + return nil +end + return SaveSlotDataService From 3972e173a1c8fe126ec0bed97dde1a1b8a0b791d Mon Sep 17 00:00:00 2001 From: Alex Turner Date: Mon, 25 May 2026 13:11:50 -0500 Subject: [PATCH 4/4] chore: Set command feedback --- src/saveslot/src/Server/Cmdr/SaveSlotCmdrService.lua | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/saveslot/src/Server/Cmdr/SaveSlotCmdrService.lua b/src/saveslot/src/Server/Cmdr/SaveSlotCmdrService.lua index 85baf5b5f5a..47df18f7ebc 100644 --- a/src/saveslot/src/Server/Cmdr/SaveSlotCmdrService.lua +++ b/src/saveslot/src/Server/Cmdr/SaveSlotCmdrService.lua @@ -98,6 +98,10 @@ function SaveSlotCmdrService._registerCommands(self: SaveSlotCmdrService): () return `No slot with index {slotIndex}.` end + if slotId == self._saveSlotDataService:GetActiveSlotId(context.Executor) then + return "Slot is already active." + end + self._maid :GivePromise(self._hasSaveSlotsBinder:Promise(context.Executor)) :Then(function(hasSaveSlots)