fix(saveslot): persist the Continue pointer apart from the active slot - #781
fix(saveslot): persist the Continue pointer apart from the active slot#781Quenty wants to merge 2 commits into
Conversation
|
Pushed a revision after an adversarial review pass. Three real defects came out of it; two were mine, one was pre-existing and directly in this change's blast radius. 1. 2. Nothing validated the seeded pointer against the slot roster. Before this PR a stale pointer was self-healing, because the next deselect cleared the key. The whole point of the new key is that it survives a deselect — which also made staleness permanent. A slot deleted by another server left a Continue button that always rejects ( 3. The precedence comment was wrong. I documented Also fixed: the new Tests: 4 new (7 total in the block). Against Not changed, for the record: |
c22347b to
630e115
Compare
One system-store key, `activeSlotId`, was doing two jobs: naming the slot selected right now, and naming the slot "Continue" resumes. A deselect clears the first by design, so backing out to the title menu wrote nil over the second. The last-active id survived only in memory (`_lastActiveSlotId`), which is why the in-session behaviour looked right and the next session came up with save slots but no Continue button. Gives the resume pointer its own persisted key, written on the same edge that already remembered it in memory, and seeds it on load as `activeSlotId or lastActiveSlotId` -- reading the active slot first is what carries players whose data predates the new key. Both pointers move through one setter each, `_setContinueSlotId` and `_setPersistedActiveSlotId`, so the in-memory value, the replicated property, and the persisted key cannot drift. A pointer is only published if the slot is actually there, and retired on disk when it is not. Before this change a stale pointer was cleared by the next deselect; now that it deliberately outlives one, nothing else ever would, so a slot deleted by another server could offer a Continue that always rejects. The persisted active slot is retired alongside the slot it names, on both delete paths -- it outlives the session that wrote it, so it can name a slot being deleted while a different one is selected. `PromiseDeleteSlot` retires it only when it actually names the slot going away, mirrored in memory rather than inferred from the Continue pointer: `PromiseResetSlot` moves that pointer alone across a datastore await, so the two can legitimately name different slots and inferring one from the other could clear a pointer to a live slot. `PromiseDeleteAllSlots` retires both outright, since every slot is going and clearing an already clear selection writes nothing. Claude-Session: https://claude.ai/code/session_01CTVd4jVT9i4U48vAfRoEVn
630e115 to
20b696f
Compare
|
Second review round. One real defect, two coverage gaps that mattered, and one finding I'm rejecting with evidence. Fixed — Fixed — the delete-path clears were untested. Both existing tests passed with those lines removed, because the load-time validation masked them. The discriminating case is a reset across a session boundary, and it's now covered: end a session inside a slot → next session resets it from the menu → rejoin must Continue on the fresh id. I verified it discriminates by deleting the clear and re-running — Fixed — the validation test didn't test the risky half. It ran with zero slots, so it proved "no slots ⇒ pointer dropped" rather than "dangling pointer dropped while the player's other slots survive". It now creates a surviving slot and asserts it's still there and still selectable afterwards, which is the assertion that would catch a false positive in the one block here that destroys persisted data. Fixed — the Rejecting one finding. The review said there is no duplicate-mock refusal, citing Accepted, not fixed: Lint, repo-wide rather than just this package: Tests: 9 in the cross-session block, |
Two conflicts, both in the load path this branch refactored. HasSaveSlots.lua: main made every hop of _promiseLoadSlots maid-owned and had each continuation re-check the object is alive, because a session-locked read settles after the player left and calling into the destroyed binder throws inside an UpdateAsync transform. This branch had extracted the pointer seeding out of that chain into _promiseSeedSlotPointers. Kept both: the extraction, with main's maid ownership and liveness guard applied to the extracted read, and the trailing `return nil` that keeps the callback's inferred return consistent. HasSaveSlots.spec.lua: main's setup() teardown now shuts the datastore down through the real close path so no auto-save loop outlives the spec; this branch made teardown idempotent and registered every session for an afterEach net. Kept both, guard first so the shutdown cannot run twice. That shutdown also settles something this branch had accepted as a risk: the rejoin helper's session lock is now released through the real close path rather than left to best-effort teardown. Claude-Session: https://claude.ai/code/session_01CTVd4jVT9i4U48vAfRoEVn
The bug
The "Continue" button disappears for a player who still has save slots.
HasSaveSlotskept one system-store key,activeSlotId, doing two jobs:Continueresumes (LastActiveSlotId,PromiseLastActiveSlotId,PromiseSelectLastSaveSlot)A deselect has to clear the first. So
PromiseDeselectSlot— backing out to the title menu — wrotenilover the second as well. The resume id survived only in memory:That in-memory value is why the behaviour looked correct in-session (and why the existing
PromiseDeselectSlotspec passes). Nothing wrote it down, so the next session loadedactiveSlotId→niland came up with slots but nothing to continue on.Repro: play a slot → return to the main menu → leave → rejoin. Continue is gone; the slot list is intact. A session that ends while still in the slot keeps the pointer, which is what made this look intermittent.
Downstream,
egg-hunt-2026drives the button straight off this property, so the menu offeredLoad Gameand noContinue:The fix
SaveSlotConstants.LAST_ACTIVE_SLOT_ID_KEY, written on the same edge that already remembered it in memory.activeSlotId or lastActiveSlotId, so a session that ended mid-play still resumes off the active slot; the new key only carries the case where the player ended at the menu._setContinueSlotId— the in-memory value, the replicated property, and the persisted key can no longer drift apart.SaveSlotConstantsrather than repeated as literals.Also fixed, because the same invariant reaches it: deleting the pointed-at slot now clears the active-slot key too. A session that ended inside a slot leaves that key naming it, and load seeds
Continuefrom it first — so a slot deleted from the menu could be offered again a session later, pointing at nothing.No behaviour change for ephemeral slots: both guards in the selection hook are untouched, and the new key is only ever written from the real-slot branch.
Tests
Three tests in a new
HasSaveSlots continue pointer across sessionsblock. Each drives a real rejoin — flush to the mock datastore, destroy the whole service bag, bind a fresh binder over the same stored bytes — so only what was persisted can survive:All three fail on
main(3 failed, 188 passed) and pass with the fix.No migration needed: any player who selects a slot writes the new key, and until then the existing
activeSlotIdstill seeds the pointer. Players already in the broken state getContinueback the first time they enter a slot throughLoad Game.https://claude.ai/code/session_01CTVd4jVT9i4U48vAfRoEVn