diff --git a/src/saveslot/src/Server/Binders/HasSaveSlots.lua b/src/saveslot/src/Server/Binders/HasSaveSlots.lua index 85cd6395474..936afdb8f12 100644 --- a/src/saveslot/src/Server/Binders/HasSaveSlots.lua +++ b/src/saveslot/src/Server/Binders/HasSaveSlots.lua @@ -19,6 +19,7 @@ local ObservableMap = require("ObservableMap") local PlayerBinder = require("PlayerBinder") local PlayerDataStoreService = require("PlayerDataStoreService") local Promise = require("Promise") +local PromiseUtils = require("PromiseUtils") local Remoting = require("Remoting") local Rx = require("Rx") local RxBrioUtils = require("RxBrioUtils") @@ -74,6 +75,7 @@ export type HasSaveSlots = _metadataStore: any, _summaryProviders: ObservableMap.ObservableMap, _lastActiveSlotId: SaveSlotData.SlotId?, + _persistedActiveSlotId: SaveSlotData.SlotId?, _teleportDataService: any, _sharedSaveSlotDataStoreService: any, _playSessionSlotId: SaveSlotData.SlotId?, @@ -782,12 +784,15 @@ function HasSaveSlots.PromiseDeleteSlot(self: HasSaveSlots, slotId: SaveSlotData self._maid[slotId] = nil - -- The continue pointer must not outlive its slot. When the deleted slot is the one the player - -- would resume on (e.g. the just-deselected active slot, or a never-reselected last-active), - -- clear the last-active memory so "Continue" stops offering a slot that no longer exists. + -- Neither pointer may outlive its slot, and they are checked apart: the persisted active slot + -- outlives the session that wrote it, so it can still name this slot while a different one is + -- selected now. Each is retired only when it is this slot that is going. if slotId == self._lastActiveSlotId then - self._lastActiveSlotId = nil - self.LastActiveSlotId.Value = nil + self:_setContinueSlotId(nil) + end + + if slotId == self._persistedActiveSlotId then + self:_setPersistedActiveSlotId(nil) end -- Wipe default slot @@ -826,8 +831,11 @@ function HasSaveSlots.PromiseDeleteAllSlots(self: HasSaveSlots): Promise.Promise return (self._loadPromise :: any):Then(function() -- Clear the selection first so the previously active slot is deletable self.ActiveSlotId.Value = nil - self._lastActiveSlotId = nil - self.LastActiveSlotId.Value = nil + + -- Every slot is going, so both pointers are retired outright rather than left to the selection hook + -- above: it writes nothing when no slot was selected, and skips an ephemeral deselect. + self:_setContinueSlotId(nil) + self:_setPersistedActiveSlotId(nil) local slotIds = {} for slotId in self._slotMap do @@ -898,8 +906,7 @@ function HasSaveSlots.PromiseResetSlot( -- Non-active reset never reselects, so carry the resume pointer onto the fresh id -- ourselves when this slot was the one "Continue" would resume. if wasLastActive then - self._lastActiveSlotId = newSlotId - self.LastActiveSlotId.Value = newSlotId + self:_setContinueSlotId(newSlotId) end return newSlotId @@ -1133,7 +1140,9 @@ function HasSaveSlots._promiseLoadSlots(self: HasSaveSlots): Promise.Promise<{}> self._systemStore = dataStore:GetSubStore(SaveSlotConstants.SYSTEM_STORE_KEY) self._metadataStore = self._systemStore:GetSubStore(SaveSlotConstants.METADATA_STORE_KEY) - return self._maid:GivePromise(self._metadataStore:LoadAll({})):Then(function(metadata) + -- Annotated because the branches differ: the liveness guard returns nil where the body returns the + -- seeding promise, and inferring from the first of those makes the other an error. + return self._maid:GivePromise(self._metadataStore:LoadAll({})):Then(function(metadata): any if not self.Destroy then return nil -- Destroyed end @@ -1142,62 +1151,101 @@ function HasSaveSlots._promiseLoadSlots(self: HasSaveSlots): Promise.Promise<{}> self:_buildSlot(slotId, data) end - return self._maid - :GivePromise(self._systemStore:Load("activeSlotId")) - :Then(function(activeId: SaveSlotData.SlotId?) - if not self.Destroy then - return nil -- Destroyed - end + return self:_promiseSeedSlotPointers() + end) + end) +end - self._lastActiveSlotId = activeId - self.LastActiveSlotId.Value = activeId - - -- The persisted active-slot pointer and the replicated "Continue" target only ever track real - -- slots. This replaces StoreOnValueChange so an ephemeral selection is invisible to both: - -- entering one leaves them pinned to the real slot, and the ephemeral slot is torn down the - -- moment it stops being active. We track the id we are leaving to know which of those to do. - local previousActiveSlotId: SaveSlotData.SlotId? = activeId - - self._maid:GiveTask(self.ActiveSlotId.Changed:Connect(function() - local active = self.ActiveSlotId.Value - - -- Replicate the active transferable-ephemeral slot's shared-store key (nil otherwise) so a - -- client-initiated teleport can carry it forward (SaveSlotServiceClient's provider reads this). - self.ActiveTransferableEphemeralKey.Value = if active - then self._transferableEphemeralKeys[active] - else nil - - local leftSlotId = previousActiveSlotId - -- Read before the retire below, while the outgoing slot is still in the map. - local leavingEphemeral = self:_isEphemeral(leftSlotId) - previousActiveSlotId = active - - -- Persist the pointer + remember the Continue target only for real-slot transitions - -- (real -> real, real -> nil deselect, nil -> real). Skip both ephemeral cases: entering an - -- ephemeral slot must stay invisible to persistence, and leaving one back to no slot must - -- leave the real pointer pinned where it was. - local enteringEphemeral = self:_isEphemeral(active) - local leavingEphemeralToMenu = leavingEphemeral and active == nil - if not (enteringEphemeral or leavingEphemeralToMenu) then - self._systemStore:Store("activeSlotId", active) - if active ~= nil then - self._lastActiveSlotId = active - self.LastActiveSlotId.Value = active - end - end +-- Moves the "Continue" pointer: what this session reads, what replicates to the client, and what carries +-- to the next session. All three together, or a consumer reads one the others have retired. +function HasSaveSlots._setContinueSlotId(self: HasSaveSlots, slotId: SaveSlotData.SlotId?): () + self._lastActiveSlotId = slotId + self.LastActiveSlotId.Value = slotId + self._systemStore:Store(SaveSlotConstants.LAST_ACTIVE_SLOT_ID_KEY, slotId) +end - -- An ephemeral slot exists only while it is the active slot; retire the one we just left. - if leavingEphemeral and leftSlotId ~= active then - self:_destroyEphemeralSlot(leftSlotId :: SaveSlotData.SlotId) - end - end)) +-- Moves the persisted active slot, mirrored in memory so a delete can tell whether the key names the slot +-- going away. Only ever written for real-slot transitions -- see the selection hook below. +function HasSaveSlots._setPersistedActiveSlotId(self: HasSaveSlots, slotId: SaveSlotData.SlotId?): () + self._persistedActiveSlotId = slotId + self._systemStore:Store(SaveSlotConstants.ACTIVE_SLOT_ID_KEY, slotId) +end - -- Matches the liveness-guard returns above, which make this callback's inferred return - -- type nil; falling off the end instead returns no values at all. - return nil - end) +-- Restores the Continue pointer from whichever key the last session left it in, then keeps both keys in +-- step with every selection this one makes. The active slot is deliberately not restored -- a session +-- starts at no slot however the last one ended. +-- +-- The read is maid-owned and the continuation re-checks we are alive, for the same reason as the rest of the +-- load it is a hop of: see _promiseLoadSlots. +function HasSaveSlots._promiseSeedSlotPointers(self: HasSaveSlots): Promise.Promise<()> + return self._maid + :GivePromise(PromiseUtils.all({ + self._systemStore:Load(SaveSlotConstants.ACTIVE_SLOT_ID_KEY), + self._systemStore:Load(SaveSlotConstants.LAST_ACTIVE_SLOT_ID_KEY), + })) + :Then(function(activeId: SaveSlotData.SlotId?, continueId: SaveSlotData.SlotId?) + if not self.Destroy then + return nil -- Destroyed + end + + -- The active slot is read first because it is the only pointer data written before the Continue key + -- existed has -- that fallback is what keeps Continue for a player upgrading onto this build. + self._persistedActiveSlotId = activeId + self._lastActiveSlotId = activeId or continueId + self.LastActiveSlotId.Value = self._lastActiveSlotId + + -- Every slot this player has is built above, so a pointer naming none of them names a slot that went + -- away -- deleted by another server, or by a session that left this key behind. Retire it on disk too: + -- now that the pointer outlives a deselect, nothing else ever would. + local seeded = self._lastActiveSlotId + if seeded ~= nil and (self._slotMap[seeded] :: Folder?) == nil then + self:_setContinueSlotId(nil) + self:_setPersistedActiveSlotId(nil) + end + + -- The persisted active-slot pointer and the replicated "Continue" target only ever track real + -- slots. This replaces StoreOnValueChange so an ephemeral selection is invisible to both: + -- entering one leaves them pinned to the real slot, and the ephemeral slot is torn down the + -- moment it stops being active. We track the id we are leaving to know which of those to do. + local previousActiveSlotId: SaveSlotData.SlotId? = activeId + + self._maid:GiveTask(self.ActiveSlotId.Changed:Connect(function() + local active = self.ActiveSlotId.Value + + -- Replicate the active transferable-ephemeral slot's shared-store key (nil otherwise) so a + -- client-initiated teleport can carry it forward (SaveSlotServiceClient's provider reads this). + self.ActiveTransferableEphemeralKey.Value = if active + then self._transferableEphemeralKeys[active] + else nil + + local leftSlotId = previousActiveSlotId + -- Read before the retire below, while the outgoing slot is still in the map. + local leavingEphemeral = self:_isEphemeral(leftSlotId) + previousActiveSlotId = active + + -- Persist the pointer + remember the Continue target only for real-slot transitions + -- (real -> real, real -> nil deselect, nil -> real). Skip both ephemeral cases: entering an + -- ephemeral slot must stay invisible to persistence, and leaving one back to no slot must + -- leave the real pointer pinned where it was. + local enteringEphemeral = self:_isEphemeral(active) + local leavingEphemeralToMenu = leavingEphemeral and active == nil + if not (enteringEphemeral or leavingEphemeralToMenu) then + self:_setPersistedActiveSlotId(active) + if active ~= nil then + self:_setContinueSlotId(active) + end + end + + -- An ephemeral slot exists only while it is the active slot; retire the one we just left. + if leavingEphemeral and leftSlotId ~= active then + self:_destroyEphemeralSlot(leftSlotId :: SaveSlotData.SlotId) + end + end)) + + -- Matches the liveness-guard return above, which makes this callback's inferred return type nil; + -- falling off the end instead returns no values at all. + return nil end) - end) end function HasSaveSlots._getSlotStore(self: HasSaveSlots, slotId: SaveSlotData.SlotId): DataStoreStage.DataStoreStage diff --git a/src/saveslot/src/Server/Binders/HasSaveSlots.spec.lua b/src/saveslot/src/Server/Binders/HasSaveSlots.spec.lua index 67f52decdea..02364dbad51 100644 --- a/src/saveslot/src/Server/Binders/HasSaveSlots.spec.lua +++ b/src/saveslot/src/Server/Binders/HasSaveSlots.spec.lua @@ -18,6 +18,7 @@ local PlayerDataStoreService = require("PlayerDataStoreService") local PlayerMock = require("PlayerMock") local PromiseTestUtils = require("PromiseTestUtils") local Rx = require("Rx") +local SaveSlotConstants = require("SaveSlotConstants") local SaveSlotDataService = require("SaveSlotDataService") local ServiceBag = require("ServiceBag") local ValueObject = require("ValueObject") @@ -25,12 +26,26 @@ local ValueObject = require("ValueObject") local HttpService = game:GetService("HttpService") local Workspace = game:GetService("Workspace") +local afterEach = Jest.Globals.afterEach local describe = Jest.Globals.describe local expect = Jest.Globals.expect local it = Jest.Globals.it local FAKE_USER_ID = 424242 +-- Every session setup() hands out, so teardown can be guaranteed from afterEach. A test that ends early -- +-- a failed assertion, or resolve() erroring below -- never reaches its own destroy(), and a mock left +-- parented is refused as a second live PlayerMockService (see PlayerMockServiceBase), which then fails every +-- later test in the file rather than just the one that broke. +local openSessions: { any } = {} + +afterEach(function() + for index = #openSessions, 1, -1 do + openSessions[index].destroy() + openSessions[index] = nil + end +end) + local function setup(mock: DataStoreMock.DataStoreMock?) mock = mock or DataStoreMock.new() @@ -50,7 +65,15 @@ local function setup(mock: DataStoreMock.DataStoreMock?) local hasSaveSlots = assert(binder:Bind(fakePlayer), "Failed to bind HasSaveSlots") hasSaveSlots.MaxSlotCount.Value = 5 + -- Idempotent, so the tests that already tear themselves down keep working and afterEach is only a net. + local destroyed = false local function destroy() + if destroyed then + return + end + + destroyed = true + -- The store the spec loaded is only destroyed by a removal, and a PlayerMock never fires the -- real Players.PlayerRemoving, so shut down the way Roblox does or its auto-save loop outlives -- this spec and fires inside a later package's window. @@ -59,7 +82,7 @@ local function setup(mock: DataStoreMock.DataStoreMock?) serviceBag:Destroy() end - return { + local context = { serviceBag = serviceBag, binder = binder, fakePlayer = fakePlayer, @@ -67,6 +90,25 @@ local function setup(mock: DataStoreMock.DataStoreMock?) mock = mock, destroy = destroy, } + + table.insert(openSessions, context) + + return context +end + +--[[ + Settles `promise` and hands back its value. Errors rather than asserting and falling through on a + promise that never settles: Yield() on a pending promise blocks forever, which would turn one failed + assertion into a whole-run timeout with no summary to read. +]] +local function resolve(promise: any, timeout: number?): any + if not PromiseTestUtils.awaitSettled(promise, timeout or 10) then + error("Promise never settled", 2) + end + + local ok, value = promise:Yield() + expect(ok).toEqual(true) + return value end describe("HasSaveSlots against a fake player (healthy datastore)", function() @@ -1447,13 +1489,6 @@ describe("HasSaveSlots against a fake player (datastore down)", function() end) describe("HasSaveSlots ephemeral slots", function() - local function resolve(promise, timeout: number?) - expect(PromiseTestUtils.awaitSettled(promise, timeout or 10)).toEqual(true) - local ok, value = promise:Yield() - expect(ok).toEqual(true) - return value - end - local function selectEphemeral(context: any, metadata: any?) return resolve(context.hasSaveSlots:PromiseSelectEphemeralSlot(metadata)) end @@ -1823,3 +1858,174 @@ describe("HasSaveSlots ephemeral slots", function() context.destroy() end) end) + +describe("HasSaveSlots continue pointer across sessions", function() + local function dataStoreFor(context: any) + local playerDataStoreService = context.serviceBag:GetService(PlayerDataStoreService) + return resolve(playerDataStoreService:PromiseDataStore(FAKE_USER_ID)) + end + + -- The substore both slot pointers live in, for asserting on (and seeding) what is actually persisted + -- rather than only what the binder reports. + local function systemStore(context: any) + return dataStoreFor(context):GetSubStore(SaveSlotConstants.SYSTEM_STORE_KEY) + end + + --[[ + Rejoins: flushes this session to the mock datastore, tears the whole service bag down, and binds a + fresh binder over the same stored bytes. Nothing carries across in memory, so whatever the returned + session knows about the player is what the last one persisted. + ]] + local function rejoin(context: any) + resolve(dataStoreFor(context):Save()) + + -- Closed here rather than left to afterEach: two live sessions for one UserId is exactly what the + -- mock refuses. Teardown runs the store through the real shutdown, so the session lock is released + -- for the next session rather than left for it to contend with. + context.destroy() + + local nextContext = setup(context.mock) + resolve(nextContext.hasSaveSlots:PromiseSlotsLoaded()) + return nextContext + end + + it("still has a slot to continue after a session that ended back at the menu", function() + local context = setup() + + local slotId = resolve(context.hasSaveSlots:PromiseCreateSlot(1)) + resolve(context.hasSaveSlots:PromiseSelectSlot(slotId)) + -- Backing out to the menu clears the active slot, which is exactly the state that used to take the + -- resume pointer with it and leave the next session with slots but no "Continue". + resolve(context.hasSaveSlots:PromiseDeselectSlot()) + + local rejoined = rejoin(context) + + expect(rejoined.hasSaveSlots.LastActiveSlotId.Value).toEqual(slotId) + expect(resolve(rejoined.hasSaveSlots:PromiseLastActiveSlotId())).toEqual(slotId) + expect(resolve(rejoined.hasSaveSlots:PromiseSelectLastSaveSlot())).toEqual(slotId) + end) + + it("continues on the slot selected last where a session used several", function() + local context = setup() + + local firstSlotId = resolve(context.hasSaveSlots:PromiseCreateSlot(1)) + local secondSlotId = resolve(context.hasSaveSlots:PromiseCreateSlot(2)) + resolve(context.hasSaveSlots:PromiseSelectSlot(firstSlotId)) + resolve(context.hasSaveSlots:PromiseSelectSlot(secondSlotId)) + resolve(context.hasSaveSlots:PromiseDeselectSlot()) + + local rejoined = rejoin(context) + + expect(rejoined.hasSaveSlots.LastActiveSlotId.Value).toEqual(secondSlotId) + end) + + it("has nothing to continue on once the slot it pointed at is deleted", function() + local context = setup() + + local slotId = resolve(context.hasSaveSlots:PromiseCreateSlot(2)) + resolve(context.hasSaveSlots:PromiseSelectSlot(slotId)) + + -- Ending this session *inside* the slot is what leaves the active-slot pointer naming it, so the + -- delete below has to invalidate that key too and not just the resume one. + local deletingSession = rejoin(context) + expect(deletingSession.hasSaveSlots.LastActiveSlotId.Value).toEqual(slotId) + + resolve(deletingSession.hasSaveSlots:PromiseDeleteSlot(slotId)) + expect(deletingSession.hasSaveSlots.LastActiveSlotId.Value).toBeNil() + + local rejoined = rejoin(deletingSession) + + expect(rejoined.hasSaveSlots.LastActiveSlotId.Value).toBeNil() + expect(resolve(rejoined.hasSaveSlots:PromiseSelectLastSaveSlot())).toBeNil() + end) + + it("has nothing to continue on after every slot is deleted from the menu", function() + local context = setup() + + local slotId = resolve(context.hasSaveSlots:PromiseCreateSlot(2)) + resolve(context.hasSaveSlots:PromiseSelectSlot(slotId)) + + -- Deleting with nothing selected is the case the selection hook cannot cover: clearing an already + -- clear selection writes nothing, so the delete has to retire the persisted active slot itself. + local deletingSession = rejoin(context) + resolve(deletingSession.hasSaveSlots:PromiseDeleteAllSlots()) + + local rejoined = rejoin(deletingSession) + + expect(rejoined.hasSaveSlots.LastActiveSlotId.Value).toBeNil() + expect(resolve(systemStore(rejoined):Load(SaveSlotConstants.ACTIVE_SLOT_ID_KEY))).toBeNil() + end) + + it("reads the pointer left by a build that only persisted the active slot", function() + local context = setup() + + local slotId = resolve(context.hasSaveSlots:PromiseCreateSlot(2)) + resolve(context.hasSaveSlots:PromiseSelectSlot(slotId)) + + -- Strips the session back to what an older build wrote: the active slot alone, with no Continue key + -- beside it. Falling back to it is what keeps Continue for every player who already has save data. + systemStore(context):Store(SaveSlotConstants.LAST_ACTIVE_SLOT_ID_KEY, nil) + + local rejoined = rejoin(context) + + expect(rejoined.hasSaveSlots.LastActiveSlotId.Value).toEqual(slotId) + end) + + it("still points at the real slot after a session spent in an ephemeral one", function() + local context = setup() + + local realId = resolve(context.hasSaveSlots:PromiseCreateSlot(2)) + resolve(context.hasSaveSlots:PromiseSelectSlot(realId)) + + -- Ending the session inside an ephemeral slot must leave no trace on either persisted pointer. + resolve(context.hasSaveSlots:PromiseSelectEphemeralSlot()) + + local rejoined = rejoin(context) + + expect(rejoined.hasSaveSlots.LastActiveSlotId.Value).toEqual(realId) + end) + + it("continues on the fresh slot after a non-active reset, across a rejoin", function() + local context = setup() + + local slotId = resolve(context.hasSaveSlots:PromiseCreateSlot(2)) + resolve(context.hasSaveSlots:PromiseSelectSlot(slotId)) + + -- Ending the session inside the slot leaves the persisted active slot naming it. Resetting it from the + -- menu next session swaps it for a fresh id and carries the Continue pointer across -- so the stale + -- active key has to be retired with the slot it named, or it shadows the fresh pointer on the rejoin + -- below and takes Continue down with it. + local resettingSession = rejoin(context) + local freshSlotId = resolve(resettingSession.hasSaveSlots:PromiseResetSlot(slotId)) + expect(freshSlotId).never.toEqual(slotId) + + local rejoined = rejoin(resettingSession) + + expect(rejoined.hasSaveSlots.LastActiveSlotId.Value).toEqual(freshSlotId) + expect(resolve(rejoined.hasSaveSlots:PromiseSelectLastSaveSlot())).toEqual(freshSlotId) + end) + + it("drops a pointer that names a slot the player no longer has, keeping the slots that remain", function() + local context = setup() + + local survivingSlotId = resolve(context.hasSaveSlots:PromiseCreateSlot(1)) + + -- What a slot deleted by another server leaves behind: pointers with no slot under them. Nothing in + -- this session saw the slot go, so the load path is the only thing that can catch it -- and now that + -- the pointer survives a deselect, nothing else would ever clear it. + systemStore(context):Store(SaveSlotConstants.ACTIVE_SLOT_ID_KEY, "slot-that-is-gone") + systemStore(context):Store(SaveSlotConstants.LAST_ACTIVE_SLOT_ID_KEY, "slot-that-is-gone") + + local rejoined = rejoin(context) + + expect(rejoined.hasSaveSlots.LastActiveSlotId.Value).toBeNil() + expect(resolve(systemStore(rejoined):Load(SaveSlotConstants.ACTIVE_SLOT_ID_KEY))).toBeNil() + expect(resolve(systemStore(rejoined):Load(SaveSlotConstants.LAST_ACTIVE_SLOT_ID_KEY))).toBeNil() + + -- Retiring the pointer must cost the player nothing else: the slot that is still there is still there, + -- and still selectable. + expect(resolve(rejoined.hasSaveSlots:PromiseHasSlot(survivingSlotId))).toEqual(true) + resolve(rejoined.hasSaveSlots:PromiseSelectSlot(survivingSlotId)) + expect(rejoined.hasSaveSlots.ActiveSlotId.Value).toEqual(survivingSlotId) + end) +end) diff --git a/src/saveslot/src/Server/SaveSlotLoadFlow.spec.lua b/src/saveslot/src/Server/SaveSlotLoadFlow.spec.lua index 983882f9ab4..17c23f4bee7 100644 --- a/src/saveslot/src/Server/SaveSlotLoadFlow.spec.lua +++ b/src/saveslot/src/Server/SaveSlotLoadFlow.spec.lua @@ -76,7 +76,7 @@ describe("save slot load flow (healthy datastore)", function() expect(metaOk).toEqual(true) expect(metadata).toEqual({}) - local activePromise = systemStore:Load("activeSlotId") + local activePromise = systemStore:Load(SaveSlotConstants.ACTIVE_SLOT_ID_KEY) if not PromiseTestUtils.awaitSettled(activePromise, 10) then expect("activeSlotId load hung").toEqual("activeSlotId load settled") controller:destroy() diff --git a/src/saveslot/src/Shared/SaveSlotConstants.lua b/src/saveslot/src/Shared/SaveSlotConstants.lua index b4daf508c26..e65d2ee13bb 100644 --- a/src/saveslot/src/Shared/SaveSlotConstants.lua +++ b/src/saveslot/src/Shared/SaveSlotConstants.lua @@ -11,6 +11,11 @@ return Table.readonly({ SYSTEM_STORE_KEY = "SaveSlots", SLOT_STORE_KEY = "slots", METADATA_STORE_KEY = "slotMetadata", + -- The slot selected right now, cleared on a deselect. + ACTIVE_SLOT_ID_KEY = "activeSlotId", + -- The slot "Continue" resumes. Persisted apart from ACTIVE_SLOT_ID_KEY because a deselect clears that + -- one by design, and the slot to resume has to outlive backing out to the menu. + LAST_ACTIVE_SLOT_ID_KEY = "lastActiveSlotId", METADATA_CONTAINER_NAME = "SaveSlots", TELEPORT_DATA_SLOT_KEY = "IncomingSaveSlotId", -- Carries the shared-store key of a transferable ephemeral slot across a teleport (trusted band).