diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c57f21f532..afda4562c9 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 0000000000..72550cf61a --- /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 0000000000..c954f9e26d --- /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 0000000000..228ea0b5d1 --- /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 0000000000..9352f84af5 --- /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 0000000000..d0a2b4bca3 --- /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 0000000000..7d7929dc4b --- /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 0000000000..2bae93f274 --- /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 0000000000..ef08a33ae7 --- /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 0000000000..ff500ec447 --- /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 0000000000..d2e8a67815 --- /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 0000000000..eb3ee92f19 --- /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 0000000000..f8eb760a19 --- /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 0000000000..13b2a7f054 --- /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 0000000000..a8428043ed --- /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 0000000000..4ce50bfe27 --- /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 0000000000..870bcce45b --- /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 0000000000..46233dac4f --- /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 0000000000..19213e5000 --- /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 0000000000..29b40177d4 --- /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 0000000000..c09e67a3ad --- /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()