Files
AppleHillsProduction/docs/puzzle_save_load_proposal.md
2025-11-03 10:08:44 +01:00

994 lines
31 KiB
Markdown

# Puzzle System Save/Load Integration - Analysis & Proposal
**Date:** November 3, 2025
**Status:** ✅ IMPLEMENTED - Awaiting Testing
---
## 🔍 Current Puzzle System Analysis
### Architecture Overview
**Key Components:**
1. **PuzzleManager** (Singleton MonoBehaviour)
- Manages all puzzle state for current scene/level
- Tracks completed steps: `HashSet<PuzzleStepSO> _completedSteps`
- Tracks unlocked steps: `HashSet<PuzzleStepSO> _unlockedSteps`
- Registers ObjectiveStepBehaviours
- Handles step dependencies and unlocking
2. **PuzzleStepSO** (ScriptableObject)
- Defines individual puzzle steps
- Has unique `stepId` (string)
- Contains dependency data (`unlocks` list)
- Cannot be instantiated per-scene (it's an asset)
3. **PuzzleLevelDataSO** (ScriptableObject)
- Container for all steps in a level
- Loaded via Addressables
- Has `levelId` and `allSteps` list
4. **ObjectiveStepBehaviour** (MonoBehaviour)
- In-scene representation of a step
- References PuzzleStepSO
- Hooks into InteractableBase
- Visual indicator management
### Current State Management
**Runtime Tracking:**
```csharp
private HashSet<PuzzleStepSO> _completedSteps = new HashSet<PuzzleStepSO>();
private HashSet<PuzzleStepSO> _unlockedSteps = new HashSet<PuzzleStepSO>();
```
**Queries:**
```csharp
public bool IsPuzzleStepCompleted(string stepId)
{
return _completedSteps.Any(step => step.stepId == stepId); // O(n) lookup!
}
```
### Current Problem
**❌ No Persistence:**
- All puzzle progress is lost on scene reload
- All puzzle progress is lost on game exit
- Players must re-complete puzzles every session
---
## 💡 Proposed Solution: Simplified Pending Registration Pattern ⭐
**Approach:** String-based tracking + pending registration queue for timing-independent save/load
**Key Insight:** Instead of complex timing logic, simply queue behaviors that register before data is restored, then update them once restoration completes.
### Core Mechanics
1. **PuzzleManager implements ISaveParticipant**
2. **Use `HashSet<string>`** for completed/unlocked steps (enables immediate restoration)
3. **Track restoration state** with `_isDataRestored` flag
4. **Queue early registrations** in `_pendingRegistrations` list
5. **Process queue** after RestoreState() completes
This elegantly handles all timing scenarios without complex branching logic.
---
## 💡 Alternative Approaches (Rejected)
### Option A: Minimal Changes (Keep ScriptableObject Tracking)
**Approach:** Make PuzzleManager implement ISaveParticipant, convert SOs to stepIds for serialization
#### Implementation
**1. Save Data Structure:**
```csharp
[Serializable]
public class PuzzleSaveData
{
public string levelId; // Which level this data is for
public List<string> completedStepIds;
public List<string> unlockedStepIds;
}
```
**2. Refactor Core Data Structures:**
```csharp
// CHANGE FROM:
private HashSet<PuzzleStepSO> _completedSteps = new HashSet<PuzzleStepSO>();
private HashSet<PuzzleStepSO> _unlockedSteps = new HashSet<PuzzleStepSO>();
// CHANGE TO:
private HashSet<string> _completedSteps = new HashSet<string>(); // Now stores stepIds
private HashSet<string> _unlockedSteps = new HashSet<string>(); // Now stores stepIds
```
**3. Add Pending Registration Pattern:**
```csharp
private bool _isDataRestored = false;
private List<ObjectiveStepBehaviour> _pendingRegistrations = new List<ObjectiveStepBehaviour>();
```
**4. Implement ISaveParticipant (Simple & Clean!):**
```csharp
public class PuzzleManager : MonoBehaviour, ISaveParticipant
{
public string GetSaveId()
{
string sceneName = SceneManager.GetActiveScene().name;
return $"{sceneName}/PuzzleManager";
}
public string SerializeState()
{
if (_currentLevelData == null)
return "{}";
var saveData = new PuzzleSaveData
{
levelId = _currentLevelData.levelId,
completedStepIds = _completedSteps.ToList(), // Direct conversion!
unlockedStepIds = _unlockedSteps.ToList() // Direct conversion!
};
return JsonUtility.ToJson(saveData);
}
public void RestoreState(string data)
{
if (string.IsNullOrEmpty(data))
return;
var saveData = JsonUtility.FromJson<PuzzleSaveData>(data);
if (saveData == null)
return;
// ✅ No timing dependency - restore IDs immediately!
_completedSteps = new HashSet<string>(saveData.completedStepIds);
_unlockedSteps = new HashSet<string>(saveData.unlockedStepIds);
_isDataRestored = true;
// ✅ Update any behaviors that registered before RestoreState was called
foreach (var behaviour in _pendingRegistrations)
{
UpdateStepState(behaviour);
}
_pendingRegistrations.Clear();
Debug.Log($"[PuzzleManager] Restored {_completedSteps.Count} completed, {_unlockedSteps.Count} unlocked steps");
}
}
```
**5. Update RegisterStepBehaviour:**
```csharp
public void RegisterStepBehaviour(ObjectiveStepBehaviour behaviour)
{
if (behaviour == null) return;
_registeredBehaviours.Add(behaviour);
if (_isDataRestored)
{
// Data already loaded - update immediately
UpdateStepState(behaviour);
}
else
{
// Data not loaded yet - queue for later
_pendingRegistrations.Add(behaviour);
}
}
```
**6. Register with SaveLoadManager:**
```csharp
private void InitializePostBoot()
{
// ...existing code...
BootCompletionService.RegisterInitAction(() =>
{
if (SaveLoadManager.Instance != null)
{
SaveLoadManager.Instance.RegisterParticipant(this);
}
});
}
void OnDestroy()
{
// ...existing code...
if (SaveLoadManager.Instance != null)
{
SaveLoadManager.Instance.UnregisterParticipant(GetSaveId());
}
}
```
#### Pros & Cons
**Pros:**
-**Simple & elegant** - minimal state tracking
-**No timing dependencies** - works in any order
-**Better performance** - O(1) lookups instead of O(n)
-**Cleaner code** - no SO ↔ string conversions
-**Easy to debug** - clear registration flow
**Cons:**
- ⚠️ Requires refactoring existing PuzzleManager methods
- ⚠️ Need to update all code that adds/checks steps
---
### Alternative: Keep ScriptableObject Tracking (Not Recommended)
**Approach:** Change internal tracking to use stepIds (strings) instead of ScriptableObject references
#### Implementation
**1. Refactor Core Data Structures:**
```csharp
// CHANGE FROM:
private HashSet<PuzzleStepSO> _completedSteps = new HashSet<PuzzleStepSO>();
private HashSet<PuzzleStepSO> _unlockedSteps = new HashSet<PuzzleStepSO>();
// CHANGE TO:
private HashSet<string> _completedSteps = new HashSet<string>(); // Now stores stepIds
private HashSet<string> _unlockedSteps = new HashSet<string>(); // Now stores stepIds
```
**2. Update Query Methods:**
```csharp
// CHANGE FROM:
public bool IsPuzzleStepCompleted(string stepId)
{
return _completedSteps.Any(step => step.stepId == stepId); // O(n)
}
// CHANGE TO:
public bool IsPuzzleStepCompleted(string stepId)
{
return _completedSteps.Contains(stepId); // O(1)!
}
```
**3. Update Step Completion Logic:**
```csharp
// CHANGE FROM:
public void CompleteStep(PuzzleStepSO step)
{
if (step == null) return;
if (_completedSteps.Contains(step)) return;
_completedSteps.Add(step);
OnStepCompleted?.Invoke(step);
// Unlock dependencies
foreach (var unlockedStep in step.unlocks)
{
UnlockStep(unlockedStep);
}
}
// CHANGE TO:
public void CompleteStep(PuzzleStepSO step)
{
if (step == null) return;
if (_completedSteps.Contains(step.stepId)) return;
_completedSteps.Add(step.stepId); // Store ID
OnStepCompleted?.Invoke(step);
// Unlock dependencies
foreach (var unlockedStep in step.unlocks)
{
UnlockStep(unlockedStep);
}
}
// Also add overload for string-based completion:
public void CompleteStep(string stepId)
{
if (string.IsNullOrEmpty(stepId)) return;
if (_completedSteps.Contains(stepId)) return;
_completedSteps.Add(stepId);
// Find the SO to fire events and unlock dependencies
var step = _currentLevelData?.allSteps.Find(s => s.stepId == stepId);
if (step != null)
{
OnStepCompleted?.Invoke(step);
foreach (var unlockedStep in step.unlocks)
{
UnlockStep(unlockedStep);
}
}
}
```
**4. Implement ISaveParticipant (Much Simpler!):**
```csharp
public class PuzzleManager : MonoBehaviour, ISaveParticipant
{
public bool HasBeenRestored { get; private set; }
public string GetSaveId()
{
string sceneName = SceneManager.GetActiveScene().name;
return $"{sceneName}/PuzzleManager";
}
public string SerializeState()
{
if (_currentLevelData == null)
return "{}";
var saveData = new PuzzleSaveData
{
levelId = _currentLevelData.levelId,
completedStepIds = _completedSteps.ToList(), // Direct conversion!
unlockedStepIds = _unlockedSteps.ToList() // Direct conversion!
};
return JsonUtility.ToJson(saveData);
}
public void RestoreState(string data)
{
if (string.IsNullOrEmpty(data))
return;
var saveData = JsonUtility.FromJson<PuzzleSaveData>(data);
if (saveData == null)
return;
// No timing dependency - we can restore IDs immediately!
_completedSteps.Clear();
_unlockedSteps.Clear();
// Direct assignment!
_completedSteps = new HashSet<string>(saveData.completedStepIds);
_unlockedSteps = new HashSet<string>(saveData.unlockedStepIds);
HasBeenRestored = true;
// Update visual state of registered behaviors
UpdateAllRegisteredBehaviors();
Debug.Log($"[PuzzleManager] Restored {_completedSteps.Count} completed steps, {_unlockedSteps.Count} unlocked steps");
}
}
```
**5. Update UnlockInitialSteps:**
```csharp
private void UnlockInitialSteps()
{
if (_currentLevelData == null) return;
// Don't unlock if we've restored from save
if (HasBeenRestored) return;
// ...rest of existing logic, but add stepIds to HashSet...
}
```
**6. Registration (Same as Option A):**
```csharp
private void InitializePostBoot()
{
// ...existing code...
BootCompletionService.RegisterInitAction(() =>
{
if (SaveLoadManager.Instance != null)
{
SaveLoadManager.Instance.RegisterParticipant(this);
}
});
}
void OnDestroy()
{
// ...existing code...
if (SaveLoadManager.Instance != null)
{
SaveLoadManager.Instance.UnregisterParticipant(GetSaveId());
}
}
```
#### Pros & Cons
**Pros:**
-**Much simpler save/load** - direct serialization
-**No timing dependencies** - can restore before level data loads
-**Better performance** - O(1) lookups instead of O(n)
-**Cleaner code** - no SO ↔ string conversions
-**More maintainable** - stepId is the canonical identifier
**Cons:**
- ⚠️ Requires refactoring existing PuzzleManager methods
- ⚠️ Need to update all code that adds/checks steps
---
## ⚠️ CRITICAL: Timing Analysis
### The Initialization Order Problem
**Question:** What if RestoreState() runs before LoadPuzzleDataForCurrentScene()?
**Answer:** It WILL happen! Here's the actual boot sequence:
```
1. BootstrapScene loads
2. SaveLoadManager initializes
3. SaveLoadManager.LoadAsync() starts
4. Gameplay scene loads (e.g., Quarry)
5. PuzzleManager.Awake() runs
6. SaveLoadManager finishes loading save data
7. ✅ SaveLoadManager.RestoreAllParticipantStates() → PuzzleManager.RestoreState() called
8. BootCompletionService.NotifyBootComplete()
9. ✅ PuzzleManager.InitializePostBoot() → LoadPuzzleDataForCurrentScene() starts (addressable async!)
10. ObjectiveStepBehaviours.Start() → RegisterStepBehaviour() calls
11. Addressable load completes → _currentLevelData available
```
**RestoreState() (step 7) runs BEFORE LoadPuzzleDataForCurrentScene() (step 9)!**
### How Each Option Handles This
#### Option A: REQUIRES Pending Data Pattern
```csharp
public void RestoreState(string data)
{
var saveData = JsonUtility.FromJson<PuzzleSaveData>(data);
if (_isDataLoaded && _currentLevelData != null)
{
// Lucky case - data already loaded (unlikely)
ApplySavedState(saveData);
}
else
{
// EXPECTED case - data not loaded yet
_pendingRestoreData = saveData; // Store for later
}
}
// Later, in LoadPuzzleDataForCurrentScene completion:
if (_pendingRestoreData != null)
{
ApplySavedState(_pendingRestoreData); // NOW we can convert stepIds to SOs
_pendingRestoreData = null;
}
```
**Why?** Can't convert stepIds to PuzzleStepSO references without _currentLevelData!
#### Option B: Works Naturally ✅
```csharp
public void RestoreState(string data)
{
var saveData = JsonUtility.FromJson<PuzzleSaveData>(data);
// No dependency on _currentLevelData needed!
_completedSteps = new HashSet<string>(saveData.completedStepIds);
_unlockedSteps = new HashSet<string>(saveData.unlockedStepIds);
HasBeenRestored = true;
// Update any behaviors that registered early (before RestoreState)
UpdateAllRegisteredBehaviors();
// Behaviors that register later will check the populated HashSets
}
```
**Why it works:**
1. ✅ stepIds are valid immediately (no ScriptableObject lookup needed)
2. ✅ Behaviors that registered early get updated
3. ✅ Behaviors that register later check against populated HashSets
4. ✅ UnlockInitialSteps() checks HasBeenRestored and skips
### All Possible Timing Permutations
| Scenario | Option A | Option B |
|----------|----------|----------|
| RestoreState → Register Behaviors → Load Level Data | Pending data, apply later | ✅ Works immediately |
| Register Behaviors → RestoreState → Load Level Data | Pending data, apply later | ✅ Updates registered behaviors |
| Load Level Data → RestoreState → Register Behaviors | Apply immediately | ✅ Works immediately |
| RestoreState → Load Level Data → Register Behaviors | Pending data, apply on load | ✅ Works immediately |
**Option B handles ALL permutations without special logic!**
### Additional Behavior Registration Timing
Even within a single scenario, behaviors can register at different times:
- Some in Start() (early)
- Some when state machines activate them (late)
- Some when player proximity triggers them
**Option B handles this gracefully:**
```csharp
public void RegisterStepBehaviour(ObjectiveStepBehaviour behaviour)
{
// ...registration logic...
// Update this behavior's state based on current HashSets
// (which may or may not be populated from RestoreState yet)
if (_isDataLoaded && _currentLevelData != null)
{
UpdateStepState(behaviour);
}
}
```
**Conclusion:** Option B's string-based approach is **timing-independent** and **more robust**.
### How ObjectiveStepBehaviour Gets Updated
**Critical Question:** When RestoreState() populates the HashSets, how do the ObjectiveStepBehaviours actually know to update their visual state?
**Answer:** Through the registration callback system already in place!
#### Current Update Flow (Before Save/Load)
```
1. ObjectiveStepBehaviour.Start()
└─ PuzzleManager.RegisterStepBehaviour(this)
└─ PuzzleManager.UpdateStepState(behaviour)
├─ Checks: Is step completed?
├─ Checks: Is step unlocked?
└─ Calls: behaviour.UnlockStep() or behaviour.LockStep()
└─ ObjectiveStepBehaviour updates visual indicator
```
#### Option B Update Flow (With Save/Load)
**Scenario 1: RestoreState BEFORE Behaviors Register**
```
1. SaveLoadManager.RestoreState() called
└─ PuzzleManager.RestoreState(data)
├─ Sets: _completedSteps = {"step1", "step2"}
├─ Sets: _unlockedSteps = {"step3"}
├─ Sets: HasBeenRestored = true
└─ Calls: UpdateAllRegisteredBehaviors()
└─ No behaviors registered yet - does nothing
2. Later: ObjectiveStepBehaviour.Start()
└─ PuzzleManager.RegisterStepBehaviour(this)
└─ PuzzleManager.UpdateStepState(behaviour)
├─ Checks: _completedSteps.Contains(behaviour.stepData.stepId)? ✅
├─ If not completed, checks: _unlockedSteps.Contains(stepId)? ✅
└─ Calls: behaviour.UnlockStep() or behaviour.LockStep()
└─ Visual indicator updates correctly! ✅
```
**Scenario 2: Behaviors Register BEFORE RestoreState**
```
1. ObjectiveStepBehaviour.Start()
└─ PuzzleManager.RegisterStepBehaviour(this)
└─ PuzzleManager.UpdateStepState(behaviour)
├─ Checks: _completedSteps (empty) - not completed
├─ Checks: _unlockedSteps (empty) - not unlocked
└─ Calls: behaviour.LockStep()
└─ Behavior locked (temporarily wrong state)
2. Later: SaveLoadManager.RestoreState() called
└─ PuzzleManager.RestoreState(data)
├─ Sets: _completedSteps = {"step1", "step2"}
├─ Sets: _unlockedSteps = {"step3"}
├─ Sets: HasBeenRestored = true
└─ Calls: UpdateAllRegisteredBehaviors()
└─ For each registered behaviour:
└─ PuzzleManager.UpdateStepState(behaviour)
├─ Checks: _completedSteps.Contains(stepId)? ✅
├─ If not completed, checks: _unlockedSteps.Contains(stepId)? ✅
└─ Calls: behaviour.UnlockStep()
└─ Visual indicator updates correctly! ✅
```
#### The Magic: UpdateStepState Method
This existing method is the key - it works for BOTH scenarios:
```csharp
private void UpdateStepState(ObjectiveStepBehaviour behaviour)
{
if (behaviour?.stepData == null) return;
// OPTION B: This becomes a simple Contains() check on strings
if (_completedSteps.Contains(behaviour.stepData.stepId))
return; // Already completed - no visual update needed
// Check if step should be unlocked
if (_unlockedSteps.Contains(behaviour.stepData.stepId))
{
behaviour.UnlockStep(); // Shows indicator, enables interaction
}
else
{
behaviour.LockStep(); // Hides indicator, disables interaction
}
}
```
#### What UnlockStep() and LockStep() Do
**UnlockStep():**
- Sets `_isUnlocked = true`
- Calls `OnShow()` → activates puzzle indicator GameObject
- Updates indicator visual state (ShowClose/ShowFar based on player distance)
- Enables interaction via InteractableBase
**LockStep():**
- Sets `_isUnlocked = false`
- Calls `OnHide()` → deactivates puzzle indicator GameObject
- Hides all visual prompts
- Disables interaction
#### Key Implementation Detail for Option B
**Current code needs ONE small update:**
```csharp
// CURRENT (uses PuzzleStepSO):
if (_completedSteps.Contains(behaviour.stepData)) return;
if (_unlockedSteps.Contains(behaviour.stepData))
// CHANGE TO (uses stepId string):
if (_completedSteps.Contains(behaviour.stepData.stepId)) return;
if (_unlockedSteps.Contains(behaviour.stepData.stepId))
```
**That's it!** The rest of the update flow stays exactly the same.
#### Why This Works So Well
1. **UpdateAllRegisteredBehaviors()** already exists - just call it after RestoreState
2. **UpdateStepState()** already exists - just change `.Contains()` to check stepIds
3. **UnlockStep/LockStep()** already exist - they handle all visual updates
4. **No timing dependencies** - works whether behaviors register before or after restore
#### Visual State Update Flow Diagram
```
RestoreState() populates HashSets
├─── UpdateAllRegisteredBehaviors()
│ └─ For each already-registered behavior:
│ └─ UpdateStepState(behaviour)
│ └─ behaviour.UnlockStep() or LockStep()
│ └─ Visual indicator updates
└─── (Future registrations)
When RegisterStepBehaviour() called:
└─ UpdateStepState(behaviour)
└─ behaviour.UnlockStep() or LockStep()
└─ Visual indicator updates
```
**Result:** All behaviors end up in the correct visual state, regardless of registration timing! ✅
### Visual Timeline Diagram
```
TIME →
Boot Sequence:
├─ SaveLoadManager.LoadAsync() starts
├─ Scene loads
├─ PuzzleManager.Awake()
├─ [CRITICAL] SaveLoadManager.RestoreState() called ← No level data yet!
│ │
│ ├─ Option A: Must store pending data ⚠️
│ │ Cannot convert stepIds to SOs yet
│ │ Must wait for addressable load
│ │
│ └─ Option B: Works immediately ✅
│ Sets HashSet<string> directly
│ No conversion needed
├─ BootCompletionService.NotifyBootComplete()
├─ PuzzleManager.InitializePostBoot()
│ └─ LoadPuzzleDataForCurrentScene() starts async
├─ ObjectiveStepBehaviours.Start() → Register with manager
│ │
│ ├─ Option A: Still waiting for level data ⚠️
│ │ Can't determine state yet
│ │
│ └─ Option B: Checks HashSets ✅
│ Immediately gets correct state
└─ Addressable load completes → _currentLevelData available
├─ Option A: NOW applies pending data ⚠️
│ Finally converts stepIds to SOs
│ Updates all behaviors
└─ Option B: Already working ✅
No action needed
```
---
## 📊 Comparison Summary
| Aspect | Option A (Keep SOs) | Option B (Use Strings) ⭐ |
|--------|---------------------|-------------------------|
| Code Changes | Minimal | Moderate |
| Save/Load Complexity | High | Low |
| Timing Dependencies | Yes (addressables) | No |
| Performance | O(n) lookups | O(1) lookups |
| Maintainability | Lower | Higher |
| Future-Proof | Less | More |
---
## 🎯 Recommendation
**I strongly recommend Option B (String-Based Tracking)** for these reasons:
1. **Simpler Save/Load** - No conversion logic, no timing dependencies
2. **Better Performance** - Faster lookups benefit gameplay
3. **More Robust** - Works regardless of addressable loading state
4. **Cleaner Architecture** - stepId is already the canonical identifier
5. **Easier to Debug** - String IDs in save files are human-readable
The refactoring effort is moderate but pays off immediately in code quality and future maintainability.
---
## 🛠️ Implementation Plan (Simplified Pending Registration Pattern) ✅
### Phase 1: Data Structure Refactor ✅
- [x] Change `_completedSteps` to `HashSet<string>`
- [x] Change `_unlockedSteps` to `HashSet<string>`
- [x] Add `_isDataRestored` flag
- [x] Add `_pendingRegistrations` list
- [x] Update `IsPuzzleStepCompleted()` to use `.Contains()` - O(1) lookup!
- [x] Update `IsStepUnlocked()` to use `.Contains(stepId)`
**Summary:** Converted internal state tracking from `HashSet<PuzzleStepSO>` to `HashSet<string>` for timing-independent save/load. Added pending registration queue pattern with `_isDataRestored` flag and `_pendingRegistrations` list.
### Phase 2: Update Step Management Methods ✅
- [x] Update `MarkPuzzleStepCompleted()` to store stepId
- [x] Update `UnlockStep()` to store stepId
- [x] Update `UnlockInitialSteps()` to add stepIds and skip if `_isDataRestored`
- [x] Update `UpdateStepState()` to check stepIds instead of SOs
- [x] Update `AreStepDependenciesMet()` to use string-based checks
- [x] Update `CheckPuzzleCompletion()` to manually iterate and check stepIds
**Summary:** Refactored all internal methods to work with string-based stepIds instead of ScriptableObject references. Simplified dependency checking from O(n²) to O(n) using HashSet.Contains(). Added restoration check to UnlockInitialSteps().
### Phase 3: Implement ISaveParticipant ✅
- [x] Add `ISaveParticipant` to PuzzleManager class signature
- [x] Add `PuzzleSaveData` structure
- [x] Implement `GetSaveId()`
- [x] Implement `SerializeState()`
- [x] Implement `RestoreState()` with pending registration processing
**Summary:** Implemented ISaveParticipant interface with direct serialization of string HashSets. RestoreState() populates HashSets immediately (no timing dependency), then processes any pending registrations that occurred before restoration.
### Phase 4: Register with SaveLoadManager ✅
- [x] Register in `InitializePostBoot()` via BootCompletionService
- [x] Unregister in `OnDestroy()`
- [x] Update `RegisterStepBehaviour()` to use pending registration pattern
- [x] Add using statement for `Core.SaveLoad`
**Summary:** Integrated with SaveLoadManager lifecycle. Updated RegisterStepBehaviour() to implement pending registration pattern: if data restored, update immediately; otherwise, queue for later processing.
### Phase 5: Testing 🔄
- [ ] Test step completion saves correctly
- [ ] Test step completion restores correctly
- [ ] Test unlocked steps save/restore
- [ ] Test scene changes preserve state
- [ ] Test game restart preserves state
- [ ] Test early vs late behavior registration timing
- [ ] Test RestoreState before vs after LoadPuzzleData
**Next Steps:** Ready for testing in Unity Editor!
---
## 📊 Implementation Summary
### What Changed
**Core Data Structures:**
- Converted `HashSet<PuzzleStepSO> _completedSteps``HashSet<string> _completedSteps`
- Converted `HashSet<PuzzleStepSO> _unlockedSteps``HashSet<string> _unlockedSteps`
- Added `bool _isDataRestored` flag for save/load state tracking
- Added `List<ObjectiveStepBehaviour> _pendingRegistrations` for timing-independent behavior updates
**ISaveParticipant Integration:**
- Implemented `GetSaveId()` - returns `"{sceneName}/PuzzleManager"`
- Implemented `SerializeState()` - directly serializes string HashSets to JSON
- Implemented `RestoreState()` - restores HashSets immediately, then processes pending registrations
- Registered with SaveLoadManager in `InitializePostBoot()` via BootCompletionService
**Pending Registration Pattern:**
```csharp
// When a behavior registers:
if (_isDataRestored) {
UpdateStepState(behaviour); // Data ready - update immediately
} else {
_pendingRegistrations.Add(behaviour); // Queue for later
}
// When RestoreState() completes:
_isDataRestored = true;
foreach (var behaviour in _pendingRegistrations) {
UpdateStepState(behaviour); // Process queued behaviors
}
_pendingRegistrations.Clear();
```
### Performance Improvements
**Before:**
- `IsPuzzleStepCompleted(stepId)`: O(n) LINQ query checking all completed steps
- `AreStepDependenciesMet()`: O(n²) nested loops comparing stepIds
**After:**
- `IsPuzzleStepCompleted(stepId)`: O(1) HashSet.Contains()
- `AreStepDependenciesMet()`: O(n) single loop with O(1) lookups
### Timing Independence
The system now handles **all timing scenarios** gracefully:
1. **RestoreState BEFORE behaviors register**
- Data restored, flag set
- Behaviors register → update immediately
2. **Behaviors register BEFORE RestoreState**
- Behaviors queued in `_pendingRegistrations`
- RestoreState() processes queue after restoration
3. **Mixed (some before, some after)**
- Early registrations queued
- RestoreState() processes queue
- Late registrations update immediately
4. **No save data exists**
- `_isDataRestored` set to true
- `UnlockInitialSteps()` runs normally
- Behaviors update on registration
---
## 🔍 Key Files Modified
1. **PuzzleManager.cs** - Core implementation
- Added `ISaveParticipant` interface
- Refactored to string-based tracking
- Implemented pending registration pattern
2. **puzzle_save_load_proposal.md** - Documentation
- Updated with simplified approach
- Marked implementation phases complete
---
## 🧪 Testing Checklist
When testing in Unity Editor, verify the following scenarios:
### Basic Functionality
- [ ] Complete a puzzle step → Save game → Restart → Load game → Step should still be completed
- [ ] Unlock a step → Save game → Restart → Load game → Step should still be unlocked
- [ ] Complete multiple steps in sequence → Verify dependencies unlock correctly after load
### Timing Tests
- [ ] Load game BEFORE puzzle behaviors initialize → Steps should restore correctly
- [ ] Load game AFTER puzzle behaviors initialize → Steps should restore correctly
- [ ] Scene change → New scene's puzzle data loads → Previous scene's data doesn't leak
### Edge Cases
- [ ] First-time player (no save file) → Initial steps unlock normally
- [ ] Save with completed steps → Delete save file → Load → Initial steps unlock
- [ ] Complete puzzle → Save → Load different scene → Load original scene → Completion persists
### Console Verification
- [ ] No errors during save operation
- [ ] No errors during load operation
- [ ] Log messages show participant registration
- [ ] Log messages show state restoration
---
## ✅ Implementation Complete
**Date Completed:** November 3, 2025
**Changes Made:**
1. ✅ Refactored PuzzleManager to use string-based HashSets
2. ✅ Implemented ISaveParticipant interface
3. ✅ Added pending registration pattern for timing independence
4. ✅ Integrated with SaveLoadManager via BootCompletionService
5. ✅ Performance improvements (O(n²) → O(n) for dependency checks)
**Files Modified:**
- `Assets/Scripts/PuzzleS/PuzzleManager.cs` - Core implementation
- `docs/puzzle_save_load_proposal.md` - Documentation and proposal
**Ready for Testing:** Yes ✅
The implementation is complete and ready for testing in Unity Editor. The system handles all timing scenarios gracefully with the simplified pending registration pattern.
---
## 📝 Additional Considerations
### Cross-Scene Puzzle State
**Question:** Should puzzle state persist across different scenes?
**Current Approach:** Each scene has its own PuzzleManager with its own level data
- Save ID: `{sceneName}/PuzzleManager`
- Each scene's puzzle state is independent
**Alternative:** Global puzzle progress tracker
- Would need a persistent PuzzleProgressManager
- Tracks completion across all levels
- More complex but enables cross-level dependencies
**Recommendation:** Start with per-scene state (simpler), add global tracker later if needed.
### Save Data Migration
If you ever need to change the save format:
- Add version number to `PuzzleSaveData`
- Implement migration logic in `RestoreState()`
- Example:
```csharp
[Serializable]
public class PuzzleSaveData
{
public int version = 1; // Add version tracking
public string levelId;
public List<string> completedStepIds;
public List<string> unlockedStepIds;
}
```
### Editor Utilities
Consider adding:
- **Context menu on PuzzleManager:** "Clear Saved Puzzle Progress"
- **Editor window:** View/edit saved puzzle state
- **Cheat commands:** Complete all steps, unlock all steps
---
## ✅ Ready to Implement
I'm ready to implement **Option B (Recommended)** or **Option A** based on your preference.
**Which option would you like me to proceed with?**
**Option A:** Minimal changes, keep ScriptableObject tracking
**Option B:** ⭐ Refactor to string-based tracking (recommended)
After you choose, I'll implement all the code changes systematically.