diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index e781a47a6a..78a71199bd 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -4293,6 +4293,66 @@ 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/propertyvalue':
+ specifier: workspace:*
+ version: link:../propertyvalue
+ '@quenty/remoting':
+ specifier: workspace:*
+ version: link:../remoting
+ '@quenty/rx':
+ specifier: workspace:*
+ version: link:../rx
+ '@quenty/servicebag':
+ specifier: workspace:*
+ version: link:../servicebag
+ '@quenty/signal':
+ specifier: workspace:*
+ version: link:../signal
+ '@quenty/table':
+ specifier: workspace:*
+ version: link:../table
+ '@quenty/tie':
+ specifier: workspace:*
+ version: link:../tie
+ '@quenty/valueobject':
+ specifier: workspace:*
+ version: link:../valueobject
+
src/scoredactionservice:
dependencies:
'@quenty/baseobject':
diff --git a/src/grouputils/src/Shared/GroupUtils.lua b/src/grouputils/src/Shared/GroupUtils.lua
index 967545a6fc..05f8429a09 100644
--- a/src/grouputils/src/Shared/GroupUtils.lua
+++ b/src/grouputils/src/Shared/GroupUtils.lua
@@ -27,6 +27,7 @@ function GroupUtils.promiseRankInGroup(player: Player, groupId: number): Promise
return Promise.spawn(function(resolve, reject)
local rank = nil
local ok, err = pcall(function()
+ -- TODO: Replace with GroupService:GetRolesInGroupAsync() once enabled
rank = player:GetRankInGroupAsync(groupId)
end)
@@ -56,6 +57,7 @@ function GroupUtils.promiseRoleInGroup(player: Player, groupId: number): Promise
return Promise.spawn(function(resolve, reject)
local role = nil
local ok, err = pcall(function()
+ -- TODO: Replace with GroupService:GetRolesInGroupAsync() once enabled
role = player:GetRoleInGroupAsync(groupId)
end)
diff --git a/src/saveslot/README.md b/src/saveslot/README.md
new file mode 100644
index 0000000000..67be1873de
--- /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..fab357b271
--- /dev/null
+++ b/src/saveslot/package.json
@@ -0,0 +1,54 @@
+{
+ "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/propertyvalue": "workspace:*",
+ "@quenty/remoting": "workspace:*",
+ "@quenty/rx": "workspace:*",
+ "@quenty/servicebag": "workspace:*",
+ "@quenty/signal": "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..a07af8f93a
--- /dev/null
+++ b/src/saveslot/src/Client/Binders/HasSaveSlotsClient.lua
@@ -0,0 +1,128 @@
+--!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 with the given ID exists
+]=]
+function HasSaveSlotsClient.PromiseHasSlot(
+ self: HasSaveSlotsClient,
+ slotId: SaveSlotData.SlotId?
+): Promise.Promise
+ return self._remoting.PromiseHasSlot:PromiseInvokeServer(slotId)
+end
+
+--[=[
+ Selects the slot with the given ID
+]=]
+function HasSaveSlotsClient.PromiseSelectSlot(
+ self: HasSaveSlotsClient,
+ slotId: SaveSlotData.SlotId
+): Promise.Promise
+ return self._remoting.PromiseSelectSlot:PromiseInvokeServer(slotId)
+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 with the given ID
+]=]
+function HasSaveSlotsClient.PromiseDeleteSlot(
+ self: HasSaveSlotsClient,
+ slotId: SaveSlotData.SlotId
+): Promise.Promise
+ return self._remoting.PromiseDeleteSlot:PromiseInvokeServer(slotId)
+end
+
+--[=[
+ Sets the metadata for the slot with the given ID
+]=]
+function HasSaveSlotsClient.PromiseSetSlotMetadata(
+ self: HasSaveSlotsClient,
+ slotId: SaveSlotData.SlotId,
+ data: SaveSlotData.SaveSlotMetadata
+): Promise.Promise
+ return self._remoting.PromiseSetSlotMetadata:PromiseInvokeServer(slotId, data)
+end
+
+--[=[
+ Gets the metadata for the slot with the given ID
+]=]
+function HasSaveSlotsClient.PromiseGetSlotMetadata(
+ self: HasSaveSlotsClient,
+ slotId: SaveSlotData.SlotId
+): Promise.Promise
+ return self._remoting.PromiseGetSlotMetadata:PromiseInvokeServer(slotId)
+end
+
+--[=[
+ Gets the last active slot ID
+]=]
+function HasSaveSlotsClient.PromiseLastActiveSlotId(self: HasSaveSlotsClient): Promise.Promise
+ return self._remoting.PromiseLastActiveSlotId:PromiseInvokeServer()
+end
+
+--[=[
+ Returns the slot ID from the given index
+]=]
+function HasSaveSlotsClient.PromiseSlotIdFromIndex(
+ self: HasSaveSlotsClient,
+ slotIndex: number
+): Promise.Promise
+ return self._remoting.PromiseSlotIdFromIndex:PromiseInvokeServer(slotIndex)
+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..f803958192
--- /dev/null
+++ b/src/saveslot/src/Server/Binders/HasSaveSlots.lua
@@ -0,0 +1,479 @@
+--!strict
+--[=[
+ @class HasSaveSlots
+]=]
+
+local require = require(script.Parent.loader).load(script)
+
+local HttpService = game:GetService("HttpService")
+
+local Binder = require("Binder")
+local Brio = require("Brio")
+local DataStoreStage = require("DataStoreStage")
+local HasSaveSlotsBase = require("HasSaveSlotsBase")
+local HasSaveSlotsInterface = require("HasSaveSlotsInterface")
+local Maid = require("Maid")
+local Observable = require("Observable")
+local PlayerBinder = require("PlayerBinder")
+local PlayerDataStoreService = require("PlayerDataStoreService")
+local Promise = require("Promise")
+local Remoting = require("Remoting")
+local Rx = require("Rx")
+local RxBrioUtils = require("RxBrioUtils")
+local SaveSlotConstants = require("SaveSlotConstants")
+local SaveSlotData = require("SaveSlotData")
+local ServiceBag = require("ServiceBag")
+local ValueObject = require("ValueObject")
+
+export type SaveSlotSummaryProvider = (Player, any) -> Observable.Observable
+
+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: { [SaveSlotData.SlotId]: Folder },
+ _loadPromise: Promise.Promise<{}>,
+ _remoting: any,
+ _dataStore: any,
+ _systemStore: any,
+ _metadataStore: any,
+ _summaryProvider: ValueObject.ValueObject,
+ _lastActiveSlotId: SaveSlotData.SlotId?,
+ },
+ {} :: 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._summaryProvider = self._maid:Add(ValueObject.new(nil))
+
+ self._loadPromise = self._maid:GivePromise(self:_promiseLoadSlots())
+
+ self._remoting = self._maid:Add(Remoting.Server.new(self._obj, "HasSaveSlots"))
+
+ self:_setupSummary()
+ self:_setupRemotes()
+
+ self._maid:GiveTask(HasSaveSlotsInterface.Server:Implement(self._obj, self))
+
+ return self
+end
+
+--[=[
+ Observes the [DataStoreStage] for the active slot as a [Brio]
+]=]
+function HasSaveSlots.ObserveActiveSlotStoreBrio(
+ self: HasSaveSlots
+): Observable.Observable>
+ return Rx.fromPromise(self._loadPromise):Pipe({
+ Rx.switchMap(function()
+ return self.ActiveSlotId
+ :ObserveBrio(function(slotId: SaveSlotData.SlotId?)
+ return (slotId ~= nil)
+ end)
+ :Pipe({
+ RxBrioUtils.map(function(slotId: SaveSlotData.SlotId)
+ return self:_getSlotStore(slotId)
+ end) :: any,
+ }) :: any
+ end) :: any,
+ }) :: any
+end
+
+--[=[
+ Returns the [DataStoreStage] for the active slot
+]=]
+function HasSaveSlots.PromiseActiveSlotStore(self: HasSaveSlots): Promise.Promise
+ return (self._loadPromise :: any):Then(function()
+ print(self.ActiveSlotId.Value)
+ if not self.ActiveSlotId.Value then
+ return (Promise :: any).resolved(nil)
+ end
+ return self:_getSlotStore(self.ActiveSlotId.Value)
+ end)
+end
+
+--[=[
+ Promises that all slots have loaded
+]=]
+function HasSaveSlots.PromiseSlotsLoaded(self: HasSaveSlots): Promise.Promise
+ return self._loadPromise
+end
+
+--[=[
+ Returns whether the slot with the given ID exists
+]=]
+function HasSaveSlots.PromiseHasSlot(self: HasSaveSlots, slotId: SaveSlotData.SlotId?): Promise.Promise
+ return (self._loadPromise :: any):Then(function()
+ return slotId and ((self._slotMap[slotId] :: Folder?) ~= nil)
+ end)
+end
+
+--[=[
+ Selects the slot with the given ID
+]=]
+function HasSaveSlots.PromiseSelectSlot(self: HasSaveSlots, slotId: SaveSlotData.SlotId): Promise.Promise
+ return (self._loadPromise :: any):Then(function()
+ if slotId == self.ActiveSlotId.Value then
+ return -- Already set
+ end
+
+ local slot = self._slotMap[slotId]
+ if not slot then
+ return (Promise :: any).rejected(`Slot \{{slotId}\} not found`)
+ end
+
+ local function setSlot()
+ self.ActiveSlotId.Value = slotId
+ SaveSlotData.LastPlayedTime:Set(slot, os.time())
+ end
+
+ -- Initialize or save and switch
+ if self.ActiveSlotId.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 (slotIndex < 1) or (slotIndex > self.MaxSlotCount.Value) then
+ return (Promise :: any).rejected(`Index must be in range [1, {self.MaxSlotCount.Value}]`)
+ end
+
+ for _, slot in self._slotMap do
+ if slotIndex == SaveSlotData.SlotIndex:Get(slot) then
+ return (Promise :: any).rejected(`Slot {slotIndex} already exists`)
+ end
+ end
+
+ local slotId = HttpService:GenerateGUID(false)
+ local data = {
+ SlotId = slotId,
+ SlotIndex = slotIndex,
+ SlotName = (metadata and metadata.SlotName) or `Slot {slotIndex}`,
+ CreatedTime = os.time(),
+ Summary = metadata and metadata.Summary,
+ }
+
+ self:_buildSlot(slotId, data, true)
+ return slotId
+ end)
+end
+
+--[=[
+ Deletes the slot with the given ID
+]=]
+function HasSaveSlots.PromiseDeleteSlot(self: HasSaveSlots, slotId: SaveSlotData.SlotId): Promise.Promise
+ return (self._loadPromise :: any):Then(function()
+ if slotId == self.ActiveSlotId.Value then
+ return (Promise :: any).rejected("Cannot delete active slot")
+ end
+
+ local slot = self._slotMap[slotId]
+ if not slot then
+ return (Promise :: any).rejected(`Slot \{{slotId}\} not found`)
+ end
+
+ self._maid[slotId] = nil
+
+ -- Wipe default slot
+ local slotIndex = SaveSlotData.SlotIndex:Get(slot)
+
+ if slotIndex == SaveSlotConstants.DEFAULT_SLOT_INDEX then
+ return self._dataStore:PromiseKeyList():Then(function(keys)
+ for _, key in keys do
+ if key ~= SaveSlotConstants.SYSTEM_STORE_KEY then
+ self._dataStore:Delete(key)
+ end
+ end
+ self._metadataStore:Delete(slotId)
+ end)
+ end
+
+ -- Or delete slot from substore
+ self._systemStore:GetSubStore(SaveSlotConstants.SLOT_STORE_KEY):Delete(slotId)
+ self._metadataStore:Delete(slotId)
+ end)
+end
+
+--[=[
+ Sets the metadata for the slot with the given ID
+]=]
+function HasSaveSlots.PromiseSetSlotMetadata(
+ self: HasSaveSlots,
+ slotId: SaveSlotData.SlotId,
+ data: SaveSlotData.SaveSlotMetadata
+): Promise.Promise
+ if data.SlotId and (data.SlotId ~= slotId) then
+ return (Promise :: any).rejected("SlotId is locked")
+ end
+
+ return (self._loadPromise :: any):Then(function()
+ local slot = self._slotMap[slotId]
+
+ -- Routing depends on immutable indices to distinguish the default slot
+ if data.SlotIndex and (data.SlotIndex ~= SaveSlotData.SlotIndex:Get(slot)) then
+ return (Promise :: any).rejected("SlotIndex is locked")
+ end
+
+ SaveSlotData:Set(slot, data)
+ end)
+end
+
+--[=[
+ Gets the metadata for the slot with the given ID
+]=]
+function HasSaveSlots.PromiseGetSlotMetadata(
+ self: HasSaveSlots,
+ slotId: SaveSlotData.SlotId
+): Promise.Promise
+ return (self._loadPromise :: any):Then(function()
+ local slot = self._slotMap[slotId]
+ return (Promise :: any).resolved(slot and SaveSlotData:Get(slot))
+ end)
+end
+
+--[=[
+ Returns the slot ID from the given index
+]=]
+function HasSaveSlots.PromiseSlotIdFromIndex(
+ self: HasSaveSlots,
+ slotIndex: number
+): Promise.Promise
+ return (self._loadPromise :: any):Then(function()
+ for slotId, slot in self._slotMap do
+ if slotIndex == SaveSlotData.SlotIndex:Get(slot) then
+ return (Promise :: any).resolved(slotId)
+ end
+ end
+ return (Promise :: any).resolved(nil)
+ end)
+end
+
+--[=[
+ Gets the last active slot ID
+]=]
+function HasSaveSlots.PromiseLastActiveSlotId(self: HasSaveSlots): Promise.Promise
+ return (self._loadPromise :: any):Then(function()
+ return self.ActiveSlotId.Value or self._lastActiveSlotId
+ end)
+end
+
+--[=[
+ Sets the summary provider
+]=]
+function HasSaveSlots.SetSummaryProvider(self: HasSaveSlots, provider: SaveSlotSummaryProvider?): ()
+ self._summaryProvider.Value = provider
+end
+
+function HasSaveSlots._promiseLoadSlots(self: HasSaveSlots): Promise.Promise<{}>
+ return self._playerDataStoreService:PromiseDataStore(self._obj):Then(function(dataStore)
+ self._dataStore = dataStore
+ self._systemStore = dataStore:GetSubStore(SaveSlotConstants.SYSTEM_STORE_KEY)
+ self._metadataStore = self._systemStore:GetSubStore(SaveSlotConstants.METADATA_STORE_KEY)
+
+ return self._metadataStore:LoadAll({}):Then(function(metadata)
+ for slotId, data in metadata do
+ self:_buildSlot(slotId, data)
+ end
+
+ return self._systemStore:Load("activeSlotId"):Then(function(activeId: SaveSlotData.SlotId?)
+ self._lastActiveSlotId = activeId
+ self._maid:GiveTask(self._systemStore:StoreOnValueChange("activeSlotId", self.ActiveSlotId))
+ end)
+ end)
+ end)
+end
+
+function HasSaveSlots._getSlotStore(self: HasSaveSlots, slotId: SaveSlotData.SlotId): DataStoreStage.DataStoreStage
+ local slot = self._slotMap[slotId]
+ if slot and (SaveSlotData.SlotIndex:Get(slot) == SaveSlotConstants.DEFAULT_SLOT_INDEX) then
+ return self._dataStore
+ end
+ return self._systemStore:GetSubStore(SaveSlotConstants.SLOT_STORE_KEY):GetSubStore(slotId)
+end
+
+function HasSaveSlots._buildSlot(
+ self: HasSaveSlots,
+ slotId: SaveSlotData.SlotId,
+ data: SaveSlotData.SaveSlotMetadata,
+ isNew: boolean?
+): ()
+ local maid = Maid.new()
+ self._maid[slotId] = maid
+
+ local slot = maid:Add(Instance.new("Folder"))
+ slot.Name = slotId
+ slot.Archivable = false
+
+ local metadataStore = self._metadataStore:GetSubStore(slotId)
+
+ local attributes = SaveSlotData:Create(slot)
+ attributes.SlotId.Value = slotId
+ attributes.SlotIndex.Value = data.SlotIndex
+
+ -- Store immutable SlotIndex once on creation
+ if isNew then
+ metadataStore:Store("SlotIndex", data.SlotIndex)
+ end
+
+ -- Store mutable metadata on change
+ for _, key in { "SlotName", "CreatedTime", "LastPlayedTime", "Summary" } do
+ attributes[key].Value = data[key]
+ maid:GiveTask(metadataStore:StoreOnValueChange(key, attributes[key]))
+
+ if isNew then
+ metadataStore:Store(key, attributes[key].Value)
+ end
+ end
+
+ slot.Parent = self._slotContainer
+
+ self._slotMap[slotId] = slot
+
+ maid:GiveTask(function()
+ self._slotMap[slotId] = nil
+ end)
+end
+
+function HasSaveSlots._setupSummary(self: HasSaveSlots): ()
+ self._maid:GiveTask(
+ self:ObserveActiveSlotStoreBrio():Subscribe(function(brio: Brio.Brio)
+ if brio:IsDead() then
+ return
+ end
+
+ local activeSlotId = self.ActiveSlotId.Value
+ local activeSlot = activeSlotId and self._slotMap[activeSlotId]
+ if not activeSlot then
+ return
+ end
+
+ local maid, slotStore = brio:ToMaidAndValue()
+
+ maid:GiveTask(self._summaryProvider
+ :Observe()
+ :Pipe({
+ Rx.switchMap(function(provider: SaveSlotSummaryProvider?)
+ if not provider then
+ return Rx.of("") :: any
+ end
+
+ local success, observable = pcall(provider, self._obj, slotStore)
+ if not success then
+ warn(`[HasSaveSlots] Summary provider errored: {observable}`)
+ return Rx.of("") :: any
+ end
+
+ return observable
+ end) :: any,
+ })
+ :Subscribe(function(summary: string)
+ if type(summary) ~= "string" then
+ warn(`[HasSaveSlots] Summary provider emitted non-string ({typeof(summary)})`)
+ return
+ end
+
+ SaveSlotData.Summary:Set(activeSlot, summary)
+ end))
+ end)
+ )
+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.PromiseSlotIdFromIndex:Bind(function(remotePlayer, ...)
+ if remotePlayer == self._obj then
+ return self:PromiseSlotIdFromIndex(...)
+ else
+ return (Promise :: any).rejected("Bad player")
+ end
+ end))
+
+ self._maid:GiveTask(self._remoting.PromiseLastActiveSlotId:Bind(function(remotePlayer)
+ if remotePlayer == self._obj then
+ return self:PromiseLastActiveSlotId()
+ 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..2a7ad84640
--- /dev/null
+++ b/src/saveslot/src/Server/Cmdr/SaveSlotCmdrService.lua
@@ -0,0 +1,183 @@
+--!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 activeSlotId = self._saveSlotDataService:GetActiveSlotId(context.Executor)
+ local slotList = self._saveSlotDataService:GetSlotList(context.Executor)
+ local listString = ""
+
+ for _, slot in slotList do
+ local isActive = (slot.SlotId == activeSlotId)
+ listString ..= `\n"{slot.SlotName}" ({slot.SlotIndex}){isActive and " — Active" or ""}\n{slot.Summary}\n`
+ end
+
+ return listString
+ end)
+
+ self._cmdrService:RegisterCommand({
+ Name = "get-active-save-slot",
+ Description = "Returns the active save slot.",
+ Group = "SaveSlots",
+ Args = {},
+ }, function(context)
+ local activeSlotId = self._saveSlotDataService:GetActiveSlotId(context.Executor)
+ if not activeSlotId then
+ return "No active slot."
+ end
+
+ local slotData = self._saveSlotDataService:GetSlotMetadata(context.Executor, activeSlotId)
+
+ return `Currently using slot {slotData.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)
+ local slotId = self._saveSlotDataService:GetSlotIdFromIndex(context.Executor, slotIndex)
+ if not slotId then
+ return `No slot with index {slotIndex}.`
+ end
+
+ if slotId == self._saveSlotDataService:GetActiveSlotId(context.Executor) then
+ return "Slot is already active."
+ end
+
+ self._maid
+ :GivePromise(self._hasSaveSlotsBinder:Promise(context.Executor))
+ :Then(function(hasSaveSlots)
+ return hasSaveSlots:PromiseSelectSlot(slotId)
+ 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 slotId = self._saveSlotDataService:GetSlotIdFromIndex(context.Executor, slotIndex)
+ if slotId then
+ return "Slot already exists."
+ end
+
+ self._maid
+ :GivePromise(self._hasSaveSlotsBinder:Promise(context.Executor))
+ :Then(function(hasSaveSlots)
+ return hasSaveSlots:PromiseCreateSlot(slotIndex)
+ end)
+ :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)
+ local slotId = self._saveSlotDataService:GetSlotIdFromIndex(context.Executor, slotIndex)
+ if not slotId then
+ return `No slot with index {slotIndex}.`
+ end
+
+ if slotId == self._saveSlotDataService:GetActiveSlotId(context.Executor) then
+ return "Cannot delete active slot."
+ end
+
+ self._maid
+ :GivePromise(self._hasSaveSlotsBinder:Promise(context.Executor))
+ :Then(function(hasSaveSlots)
+ return hasSaveSlots:PromiseDeleteSlot(slotId)
+ 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..99676769a5
--- /dev/null
+++ b/src/saveslot/src/Server/SaveSlotService.lua
@@ -0,0 +1,229 @@
+--!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 Promise = require("Promise")
+local Remoting = require("Remoting")
+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,
+ _hasSaveSlotsBinder: any,
+ _selectionRequired: boolean,
+ _maxSlotCount: number,
+ _defaultSummaryProvider: HasSaveSlots.SaveSlotSummaryProvider?,
+ _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._serviceBag:GetService(require("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._defaultSummaryProvider then
+ hasSaveSlots:SetSummaryProvider(self._defaultSummaryProvider)
+ end
+
+ maid:GivePromise(hasSaveSlots:PromiseSlotsLoaded()):Then(function()
+ if self._selectionRequired then
+ return -- Consumer handles selection
+ end
+
+ -- Select last active slot
+ return hasSaveSlots:PromiseLastActiveSlotId():Then(function(lastActiveSlotId: SaveSlotData.SlotId?)
+ return hasSaveSlots:PromiseHasSlot(lastActiveSlotId):Then(function(hasLastSlot: boolean)
+ if hasLastSlot then
+ return hasSaveSlots:PromiseSelectSlot(lastActiveSlotId)
+ end
+
+ -- Or create and select default slot
+ return hasSaveSlots
+ :PromiseSlotIdFromIndex(SaveSlotConstants.DEFAULT_SLOT_INDEX)
+ :Then(function(defaultSlotId: SaveSlotData.SlotId?)
+ if defaultSlotId then
+ return defaultSlotId
+ else
+ return hasSaveSlots:PromiseCreateSlot(SaveSlotConstants.DEFAULT_SLOT_INDEX)
+ end
+ end)
+ :Then(function(slotId: SaveSlotData.SlotId)
+ return hasSaveSlots:PromiseSelectSlot(slotId)
+ 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 default slot summary provider
+]=]
+function SaveSlotService.SetDefaultSummaryProvider(
+ self: SaveSlotService,
+ provider: HasSaveSlots.SaveSlotSummaryProvider
+): ()
+ assert(type(provider) == "function", "Bad provider")
+ self._defaultSummaryProvider = provider
+end
+
+--[=[
+ Observes the [DataStoreStage] for the player's active slot as a [Brio]
+]=]
+function SaveSlotService.ObserveActiveSlotStoreBrio(
+ self: SaveSlotService,
+ player: Player
+): Observable.Observable>
+ return self._hasSaveSlotsBinder:ObserveBrio(player):Pipe({
+ RxBrioUtils.switchMapBrio(function(hasSaveSlots)
+ return hasSaveSlots:ObserveActiveSlotStoreBrio()
+ 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:PromiseActiveSlotStore()
+ end)
+end
+
+--[=[
+ Returns whether the player has a slot with the given ID
+]=]
+function SaveSlotService.PromiseHasSlot(
+ self: SaveSlotService,
+ player: Player,
+ slotId: SaveSlotData.SlotId
+): Promise.Promise
+ return self._hasSaveSlotsBinder:Promise(player):Then(function(hasSaveSlots)
+ return hasSaveSlots:PromiseHasSlot(slotId)
+ end)
+end
+
+--[=[
+ Selects the slot with the given ID for the player
+]=]
+function SaveSlotService.PromiseSelectSlot(
+ self: SaveSlotService,
+ player: Player,
+ slotId: SaveSlotData.SlotId
+): Promise.Promise
+ return self._hasSaveSlotsBinder:Promise(player):Then(function(hasSaveSlots)
+ return hasSaveSlots:PromiseSelectSlot(slotId)
+ 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 with the given ID for the player
+]=]
+function SaveSlotService.PromiseDeleteSlot(
+ self: SaveSlotService,
+ player: Player,
+ slotId: SaveSlotData.SlotId
+): Promise.Promise
+ return self._hasSaveSlotsBinder:Promise(player):Then(function(hasSaveSlots)
+ return hasSaveSlots:PromiseDeleteSlot(slotId)
+ 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..b3679df19d
--- /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({
+ ActiveSlotId = AdorneeDataEntry.optionalAttribute("string", "ActiveSlotId"),
+ MaxSlotCount = math.huge,
+})
diff --git a/src/saveslot/src/Shared/Data/SaveSlotData.lua b/src/saveslot/src/Shared/Data/SaveSlotData.lua
new file mode 100644
index 0000000000..e2bd738aad
--- /dev/null
+++ b/src/saveslot/src/Shared/Data/SaveSlotData.lua
@@ -0,0 +1,32 @@
+--!strict
+--[=[
+ @class SaveSlotData
+]=]
+
+local require = require(script.Parent.loader).load(script)
+
+local AdorneeData = require("AdorneeData")
+local AdorneeDataEntry = require("AdorneeDataEntry")
+local PropertyValue = require("PropertyValue")
+
+export type SlotId = string
+
+export type SaveSlotMetadata = {
+ SlotId: SlotId,
+ SlotIndex: number,
+ SlotName: string?,
+ CreatedTime: number?,
+ LastPlayedTime: number?,
+ Summary: string?,
+}
+
+return AdorneeData.new({
+ SlotId = AdorneeDataEntry.new("string", function(folder: Folder)
+ return PropertyValue.new(folder, "Name")
+ end),
+ SlotIndex = 0,
+ SlotName = AdorneeDataEntry.optionalAttribute("string", "SlotName"),
+ CreatedTime = AdorneeDataEntry.optionalAttribute("number", "CreatedTime"),
+ 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..daafce1be5
--- /dev/null
+++ b/src/saveslot/src/Shared/HasSaveSlotsBase.lua
@@ -0,0 +1,50 @@
+--!strict
+--[=[
+ @class HasSaveSlotsBase
+]=]
+
+local require = require(script.Parent.loader).load(script)
+
+local BaseObject = require("BaseObject")
+local HasSaveSlotsData = require("HasSaveSlotsData")
+local SaveSlotData = require("SaveSlotData")
+local ServiceBag = require("ServiceBag")
+local Signal = require("Signal")
+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,
+
+ ActiveSlotId: ValueObject.ValueObject,
+ MaxSlotCount: ValueObject.ValueObject,
+
+ SlotChanged: Signal.Signal,
+ },
+ {} :: 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.ActiveSlotId = self._attributes.ActiveSlotId
+ self.MaxSlotCount = self._attributes.MaxSlotCount
+
+ self.SlotChanged = self.ActiveSlotId.Changed
+
+ 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..c681ec51ae
--- /dev/null
+++ b/src/saveslot/src/Shared/HasSaveSlotsInterface.lua
@@ -0,0 +1,29 @@
+--!strict
+--[=[
+ @class HasSaveSlotsInterface
+]=]
+
+local require = require(script.Parent.loader).load(script)
+
+local TieDefinition = require("TieDefinition")
+
+return TieDefinition.new("HasSaveSlots", {
+ ActiveSlotId = 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,
+ PromiseSlotIdFromIndex = TieDefinition.Types.METHOD,
+ PromiseLastActiveSlotId = TieDefinition.Types.METHOD,
+
+ SlotChanged = TieDefinition.Types.SIGNAL,
+
+ [TieDefinition.Realms.SERVER] = {
+ ObserveActiveSlotStoreBrio = TieDefinition.Types.METHOD,
+ PromiseActiveSlotStore = TieDefinition.Types.METHOD,
+ },
+})
diff --git a/src/saveslot/src/Shared/SaveSlotConstants.lua b/src/saveslot/src/Shared/SaveSlotConstants.lua
new file mode 100644
index 0000000000..b01dfa1fcb
--- /dev/null
+++ b/src/saveslot/src/Shared/SaveSlotConstants.lua
@@ -0,0 +1,16 @@
+--!strict
+--[=[
+ @class SaveSlotConstants
+]=]
+
+local require = require(script.Parent.loader).load(script)
+
+local Table = require("Table")
+
+return Table.readonly({
+ SYSTEM_STORE_KEY = "SaveSlots",
+ SLOT_STORE_KEY = "slots",
+ METADATA_STORE_KEY = "slotMetadata",
+ METADATA_CONTAINER_NAME = "SaveSlots",
+ DEFAULT_SLOT_INDEX = 1,
+})
diff --git a/src/saveslot/src/Shared/SaveSlotDataService.lua b/src/saveslot/src/Shared/SaveSlotDataService.lua
new file mode 100644
index 0000000000..afc56e7e63
--- /dev/null
+++ b/src/saveslot/src/Shared/SaveSlotDataService.lua
@@ -0,0 +1,157 @@
+--!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 ID
+]=]
+function SaveSlotDataService.ObserveActiveSlotId(
+ self: SaveSlotDataService,
+ player: Player
+): Observable.Observable
+ return (HasSaveSlotsInterface:ObserveBrio(player, self._realm) :: any):Pipe({
+ RxBrioUtils.switchMapBrio(function(hasSaveSlots)
+ return hasSaveSlots.ActiveSlotId:Observe()
+ end),
+ RxBrioUtils.emitOnDeath(nil),
+ })
+end
+
+--[=[
+ Returns the player's active slot ID
+]=]
+function SaveSlotDataService.GetActiveSlotId(self: SaveSlotDataService, player: Player): SaveSlotData.SlotId?
+ local hasSaveSlots = HasSaveSlotsInterface:Find(player, self._realm)
+ return hasSaveSlots and hasSaveSlots.ActiveSlotId.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 with the given ID for the player
+]=]
+function SaveSlotDataService.ObserveSlotMetadata(
+ _self: SaveSlotDataService,
+ player: Player,
+ slotId: SaveSlotData.SlotId
+): Observable.Observable
+ return (
+ RxInstanceUtils.observeLastNamedChildBrio(player, "Folder", SaveSlotConstants.METADATA_CONTAINER_NAME) :: any
+ ):Pipe({
+ RxBrioUtils.switchMapBrio(function(slotContainer: Folder)
+ return RxInstanceUtils.observeLastNamedChildBrio(slotContainer, "Folder", slotId)
+ end),
+ RxBrioUtils.emitOnDeath(nil),
+ Rx.switchMap(function(slot: Folder?)
+ return slot and SaveSlotData:Observe(slot) or Rx.EMPTY
+ end),
+ })
+end
+
+--[=[
+ Returns the slot metadata with the given ID for the player
+]=]
+function SaveSlotDataService.GetSlotMetadata(
+ _self: SaveSlotDataService,
+ player: Player,
+ slotId: SaveSlotData.SlotId
+): SaveSlotData.SaveSlotMetadata?
+ local slotContainer = player:FindFirstChild(SaveSlotConstants.METADATA_CONTAINER_NAME)
+ local slot = slotContainer and slotContainer:FindFirstChild(slotId)
+
+ if slot then
+ return SaveSlotData:Get(slot)
+ else
+ return nil
+ end
+end
+
+--[=[
+ Returns the ID for the slot at the given index
+]=]
+function SaveSlotDataService.GetSlotIdFromIndex(
+ self: SaveSlotDataService,
+ player: Player,
+ slotIndex: number
+): SaveSlotData.SlotId?
+ for _, slot in self:GetSlotList(player) do
+ if slotIndex == slot.SlotIndex then
+ return slot.SlotId
+ end
+ end
+ return nil
+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()