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
+
+
+
+PlayerDataStoreService wrapper for save slots
+
+
+
+## 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()