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

31 KiB

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:

private HashSet<PuzzleStepSO> _completedSteps = new HashSet<PuzzleStepSO>();
private HashSet<PuzzleStepSO> _unlockedSteps = new HashSet<PuzzleStepSO>();

Queries:

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:

[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:

// 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:

private bool _isDataRestored = false;
private List<ObjectiveStepBehaviour> _pendingRegistrations = new List<ObjectiveStepBehaviour>();

4. Implement ISaveParticipant (Simple & Clean!):

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:

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:

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

Approach: Change internal tracking to use stepIds (strings) instead of ScriptableObject references

Implementation

1. Refactor Core Data Structures:

// 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:

// 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:

// 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!):

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:

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):

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

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

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:

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:

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:

// 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

  • Change _completedSteps to HashSet<string>
  • Change _unlockedSteps to HashSet<string>
  • Add _isDataRestored flag
  • Add _pendingRegistrations list
  • Update IsPuzzleStepCompleted() to use .Contains() - O(1) lookup!
  • 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

  • Update MarkPuzzleStepCompleted() to store stepId
  • Update UnlockStep() to store stepId
  • Update UnlockInitialSteps() to add stepIds and skip if _isDataRestored
  • Update UpdateStepState() to check stepIds instead of SOs
  • Update AreStepDependenciesMet() to use string-based checks
  • 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

  • Add ISaveParticipant to PuzzleManager class signature
  • Add PuzzleSaveData structure
  • Implement GetSaveId()
  • Implement SerializeState()
  • 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

  • Register in InitializePostBoot() via BootCompletionService
  • Unregister in OnDestroy()
  • Update RegisterStepBehaviour() to use pending registration pattern
  • 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> _completedStepsHashSet<string> _completedSteps
  • Converted HashSet<PuzzleStepSO> _unlockedStepsHashSet<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:

// 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:
[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.