From 191fb3db3f0694fcadfb69552d995db8931609ff Mon Sep 17 00:00:00 2001 From: beo3000 Date: Sat, 27 Dec 2025 20:51:03 +0100 Subject: [PATCH] fix undo/redo handling --- .../Components/Game/GameInputPanel.razor | 22 ++++++++++++-- src/Koogle.Web/Store/GameState/GameEffects.cs | 15 ++++++++-- .../Store/GameState/GameReducers.cs | 29 +++++++++++++++---- 3 files changed, 56 insertions(+), 10 deletions(-) diff --git a/src/Koogle.Web/Components/Game/GameInputPanel.razor b/src/Koogle.Web/Components/Game/GameInputPanel.razor index 3609731..e05e96a 100644 --- a/src/Koogle.Web/Components/Game/GameInputPanel.razor +++ b/src/Koogle.Web/Components/Game/GameInputPanel.razor @@ -163,6 +163,7 @@ private int? _selectedNumber; private bool _hasModifiedPins; private ThrowPanelState? _beforeThrowState; + private int _lastKnownThrowCounter; private bool IsInteractive => GameState.Value.IsGameActive && !GameState.Value.IsLoading; @@ -172,15 +173,30 @@ GameState.StateChanged += OnGameStateChanged; // Store initial state for before/after comparison CaptureBeforeThrowState(); + _lastKnownThrowCounter = GameState.Value.ThrowPanel.TotalThrowCounter; } private void OnGameStateChanged(object? sender, EventArgs e) { - // After throw is processed and pins are reset, capture new before state - if (!_hasModifiedPins) + var currentCounter = GameState.Value.ThrowPanel.TotalThrowCounter; + + // Detect external state changes (Undo/Redo/SignalR) by comparing throw counter + // If counter changed externally, reset local state and capture new before state + if (currentCounter != _lastKnownThrowCounter) { + _hasModifiedPins = false; + _selectedNumber = null; + CaptureBeforeThrowState(); + _lastKnownThrowCounter = currentCounter; + } + else if (!_hasModifiedPins) + { + // Normal state change (e.g., after throw processed), capture new before state CaptureBeforeThrowState(); } + + // Force re-render to update child components (ThrowPanel, PinPanel, etc.) + InvokeAsync(StateHasChanged); } private void CaptureBeforeThrowState() @@ -201,7 +217,7 @@ var currentStatus = GetPinStatus(pinNumber); // In Decrease mode, fallen pins cannot be reset to standing - if (GameState.Value.ThrowPanel.ThrowMode == ThrowMode.Decrease && currentStatus == PinStatus.Fallen) + if (GameState.Value.ThrowPanel.ThrowMode == ThrowMode.Decrease && currentStatus == PinStatus.Disabled) { return; } diff --git a/src/Koogle.Web/Store/GameState/GameEffects.cs b/src/Koogle.Web/Store/GameState/GameEffects.cs index 5f40672..7ffebdc 100644 --- a/src/Koogle.Web/Store/GameState/GameEffects.cs +++ b/src/Koogle.Web/Store/GameState/GameEffects.cs @@ -467,6 +467,14 @@ public class GameEffects [EffectMethod] public Task HandleUndoThrowSuccess(UndoThrowSuccessAction action, IDispatcher dispatcher) { + //var stateAfterReducer = _gameState.Value; + //_logger.LogInformation( + // "UNDO SUCCESS: State after reducer - pins fallen={Fallen}, total={Total}, UndoStack={UndoCount}, RedoStack={RedoCount}", + // stateAfterReducer.ThrowPanel.CountFallenPins(), + // stateAfterReducer.ThrowPanel.TotalThrowCounter, + // stateAfterReducer.UndoStack.Count, + // stateAfterReducer.RedoStack.Count); + // Save after undo as well _saveDebounceTimer?.Dispose(); _saveDebounceTimer = new Timer( @@ -495,9 +503,12 @@ public class GameEffects var lastSnapshot = state.UndoStack[^1]; - _logger.LogDebug( - "Undoing throw. Stack size before: {StackSize}, Restoring to throw counter: {ThrowCounter}", + _logger.LogInformation( + "UNDO: Stack={StackSize}, Current pins fallen={CurrentFallen}, Snapshot pins fallen={SnapshotFallen}, Current total={CurrentTotal}, Snapshot total={SnapshotTotal}", state.UndoStack.Count, + state.ThrowPanel.CountFallenPins(), + lastSnapshot.ThrowPanel.CountFallenPins(), + state.ThrowPanel.TotalThrowCounter, lastSnapshot.ThrowPanel.TotalThrowCounter); dispatcher.Dispatch(new UndoThrowSuccessAction( diff --git a/src/Koogle.Web/Store/GameState/GameReducers.cs b/src/Koogle.Web/Store/GameState/GameReducers.cs index b75261f..d22e15f 100644 --- a/src/Koogle.Web/Store/GameState/GameReducers.cs +++ b/src/Koogle.Web/Store/GameState/GameReducers.cs @@ -1,5 +1,7 @@ using System.Collections.Immutable; +using System.Text.Json; using Fluxor; +using Koogle.Application.Games; using Koogle.Domain.Enums; namespace Koogle.Web.Store.GameState; @@ -191,11 +193,14 @@ public static class GameReducers public static GameState OnRecordThrow(GameState state, RecordThrowAction action) { // Create snapshot of current state before throw is processed + // Deep clone GameModel to avoid reference sharing issues + object? clonedGameModel = CloneGameModel(state.GameModel); + var snapshot = new GameSnapshot { ThrowPanel = state.ThrowPanel, Participants = state.Participants, - GameModel = state.GameModel + GameModel = clonedGameModel }; return state with @@ -322,12 +327,12 @@ public static class GameReducers [ReducerMethod] public static GameState OnUndoThrowSuccess(GameState state, UndoThrowSuccessAction action) { - // Create snapshot of current state for redo + // Create snapshot of current state for redo (deep clone GameModel) var currentSnapshot = new GameSnapshot { ThrowPanel = state.ThrowPanel, Participants = state.Participants, - GameModel = state.GameModel + GameModel = CloneGameModel(state.GameModel) }; // Remove the last snapshot from undo stack @@ -364,12 +369,12 @@ public static class GameReducers [ReducerMethod] public static GameState OnRedoThrowSuccess(GameState state, RedoThrowSuccessAction action) { - // Create snapshot of current state for undo + // Create snapshot of current state for undo (deep clone GameModel) var currentSnapshot = new GameSnapshot { ThrowPanel = state.ThrowPanel, Participants = state.Participants, - GameModel = state.GameModel + GameModel = CloneGameModel(state.GameModel) }; // Remove the last snapshot from redo stack @@ -508,4 +513,18 @@ public static class GameReducers { GameModel = action.GameModel }; + + // Helper Methods + + /// + /// Deep clones a GameModel using JSON serialization. + /// + private static object? CloneGameModel(object? gameModel) + { + if (gameModel == null) + return null; + + var json = JsonSerializer.Serialize(gameModel, gameModel.GetType(), GameModelFactory.JsonSerializerOptions); + return JsonSerializer.Deserialize(json, gameModel.GetType(), GameModelFactory.JsonSerializerOptions); + } }