using System.Text.Json; using Fluxor; using Koogle.Application.DTOs; using Koogle.Application.Games; using Koogle.Application.Interfaces; using Koogle.Domain.Enums; using Koogle.Web.Hubs; using Koogle.Web.Services; namespace Koogle.Web.Store.GameState; /// /// Side effects for game state management. /// Handles async operations like persistence, state recovery, and SignalR broadcasts. /// public class GameEffects { private readonly ILogger _logger; private readonly IState _gameState; private readonly IGamePersistenceService _persistenceService; private readonly ICurrentClubContext _clubContext; private readonly GameDefinitionRegistry _gameRegistry; private readonly GameHubService _hubService; // Debounce timer for save operations private Timer? _saveDebounceTimer; private const int SaveDebounceMs = 500; /// /// Initializes a new instance of the GameEffects class. /// public GameEffects( ILogger logger, IState gameState, IGamePersistenceService persistenceService, ICurrentClubContext clubContext, GameDefinitionRegistry gameRegistry, GameHubService hubService) { _logger = logger; _gameState = gameState; _persistenceService = persistenceService; _clubContext = clubContext; _gameRegistry = gameRegistry; _hubService = hubService; } /// /// Handles StartGameAction - creates game entity in database. /// [EffectMethod] public async Task HandleStartGame(StartGameAction action, IDispatcher dispatcher) { try { var initialState = new GameStateSerializationDto { ThrowPanelAfter = MapThrowPanelToDto(ThrowPanelState.Initial with { IsStarted = true, ThrowsPerRound = action.ThrowsPerRound, ThrowMode = action.ThrowMode }), ThrowPanelBefore = MapThrowPanelToDto(ThrowPanelState.Initial with { IsStarted = true, ThrowsPerRound = action.ThrowsPerRound, ThrowMode = action.ThrowMode }), Participants = new ParticipantsStateDto { PlayerIds = action.PlayerIds, CurrentPlayerIndex = 0, Mode = (int)action.ParticipantsMode }, GameModel = action.InitialGameModel != null ? JsonSerializer.SerializeToElement(action.InitialGameModel, GameStateSerializationDto.JsonOptions) : null, Setup = action.Setup != null ? JsonSerializer.SerializeToElement(action.Setup, GameStateSerializationDto.JsonOptions) : null, UndoStack = [] }; var createDto = new CreateGameDto { DayId = action.DayId, ClubId = _clubContext.ClubId, GameType = action.GameTypeName, PlayerIds = action.PlayerIds, InitialGameStateJson = JsonSerializer.Serialize(initialState, GameStateSerializationDto.JsonOptions) }; var gameId = await _persistenceService.CreateGameAsync(createDto); var throwPanel = ThrowPanelState.Initial with { IsStarted = true, ThrowsPerRound = action.ThrowsPerRound, ThrowMode = action.ThrowMode }; var participants = new ParticipantsState { PlayerIds = action.PlayerIds, CurrentPlayerIndex = 0, Mode = action.ParticipantsMode }; dispatcher.Dispatch(new StartGameSuccessAction( gameId, action.GameTypeName, throwPanel, participants, action.InitialGameModel, action.Setup)); // Broadcast game started via SignalR await _hubService.BroadcastGameStartedAsync(action.DayId, gameId, action.GameTypeName); _logger.LogInformation("Game started: {GameId}, Type: {GameType}", gameId, action.GameTypeName); } catch (Exception ex) { _logger.LogError(ex, "Failed to start game"); dispatcher.Dispatch(new StartGameFailureAction($"Spiel konnte nicht gestartet werden: {ex.Message}")); } } /// /// Handles EndGameAction - updates game status in database. /// [EffectMethod] public async Task HandleEndGame(EndGameAction action, IDispatcher dispatcher) { var state = _gameState.Value; if (!state.ActiveGameId.HasValue) { dispatcher.Dispatch(new EndGameFailureAction("Kein aktives Spiel vorhanden")); return; } try { // Save final state before ending await SaveCurrentStateAsync(state.ActiveGameId.Value); await _persistenceService.EndGameAsync(state.ActiveGameId.Value, action.FinalStatus); var summary = new GameSummaryDto { Id = state.ActiveGameId.Value, GameTypeName = state.GameTypeName ?? "", Status = action.FinalStatus, StartedAt = DateTime.UtcNow, // Will be overwritten by actual value from DB CompletedAt = DateTime.UtcNow, ParticipantCount = state.Participants.PlayerIds.Length }; dispatcher.Dispatch(new EndGameSuccessAction(summary)); // Broadcast game ended via SignalR var result = new GameResultDto { GameId = state.ActiveGameId.Value, WinnerId = action.WinnerId, WinnerName = action.WinnerName, FinalScores = action.FinalScores, GameTypeName = state.GameTypeName ?? "", CompletedAt = DateTime.UtcNow }; await _hubService.BroadcastGameEndedAsync(state.ActiveGameId.Value, result); _logger.LogInformation("Game ended: {GameId}, Status: {Status}", state.ActiveGameId, action.FinalStatus); } catch (Exception ex) { _logger.LogError(ex, "Failed to end game: {GameId}", state.ActiveGameId); dispatcher.Dispatch(new EndGameFailureAction($"Spiel konnte nicht beendet werden: {ex.Message}")); } } /// /// Handles LoadActiveGameAction - loads active game from database for recovery. /// [EffectMethod] public async Task HandleLoadActiveGame(LoadActiveGameAction action, IDispatcher dispatcher) { try { var activeGame = await _persistenceService.LoadActiveGameAsync(action.DayId); if (activeGame == null) { dispatcher.Dispatch(new NoActiveGameAction()); return; } var stateDto = JsonSerializer.Deserialize( activeGame.GameStateJson, GameStateSerializationDto.JsonOptions); if (stateDto == null) { _logger.LogWarning("Failed to deserialize game state for game: {GameId}", activeGame.Id); dispatcher.Dispatch(new NoActiveGameAction()); return; } var throwPanelBefore = MapDtoToThrowPanel(stateDto.ThrowPanelBefore); var throwPanelAfter = MapDtoToThrowPanel(stateDto.ThrowPanelAfter); var participants = MapDtoToParticipants(stateDto.Participants); var undoStack = stateDto.UndoStack .Select(s => new GameSnapshot { ThrowPanelBefore = MapDtoToThrowPanel(s.ThrowPanelBefore), ThrowPanelAfter = MapDtoToThrowPanel(s.ThrowPanelAfter), Participants = MapDtoToParticipants(s.Participants), GameModel = s.GameModel.HasValue ? (object?)s.GameModel.Value : null }) .ToList(); var redoStack = stateDto.RedoStack .Select(s => new GameSnapshot { ThrowPanelBefore = MapDtoToThrowPanel(s.ThrowPanelBefore), ThrowPanelAfter = MapDtoToThrowPanel(s.ThrowPanelAfter), Participants = MapDtoToParticipants(s.Participants), GameModel = s.GameModel.HasValue ? (object?)s.GameModel.Value : null }) .ToList(); // Deserialize setup if present (may be null for legacy games) IGameSetupModel? setup = null; if (stateDto.Setup.HasValue) { setup = JsonSerializer.Deserialize( stateDto.Setup.Value.GetRawText(), GameStateSerializationDto.JsonOptions); } dispatcher.Dispatch(new LoadActiveGameSuccessAction( activeGame.Id, activeGame.GameType, throwPanelBefore, throwPanelAfter, participants, stateDto.GameModel.HasValue ? (object?)stateDto.GameModel.Value : null, undoStack, redoStack, setup)); _logger.LogInformation("Active game loaded: {GameId}, Type: {GameType}", activeGame.Id, activeGame.GameType); } catch (Exception ex) { _logger.LogError(ex, "Failed to load active game for day: {DayId}", action.DayId); dispatcher.Dispatch(new LoadActiveGameFailureAction($"Spiel konnte nicht geladen werden: {ex.Message}")); } } /// /// Handles LoadCompletedGamesAction - loads completed games list from database. /// [EffectMethod] public async Task HandleLoadCompletedGames(LoadCompletedGamesAction action, IDispatcher dispatcher) { try { var games = await _persistenceService.GetCompletedGamesAsync(action.DayId); // Map Application DTOs to Web DTOs var webGames = games.Select(g => new GameSummaryDto { Id = g.Id, GameTypeName = g.GameTypeName, Status = g.Status, StartedAt = g.StartedAt, CompletedAt = g.CompletedAt, ParticipantCount = g.ParticipantCount }).ToList(); dispatcher.Dispatch(new LoadCompletedGamesSuccessAction(webGames)); } catch (Exception ex) { _logger.LogError(ex, "Failed to load completed games for day: {DayId}", action.DayId); dispatcher.Dispatch(new LoadCompletedGamesFailureAction($"Spiele konnten nicht geladen werden: {ex.Message}")); } } /// /// Handles RecordThrowAction - calls ProcessThrow and applies game logic. /// [EffectMethod] public Task HandleRecordThrow(RecordThrowAction action, IDispatcher dispatcher) { var state = _gameState.Value; var gameTypeName = state.GameTypeName; var currentPlayerId = state.Participants.CurrentPlayerId; if (string.IsNullOrEmpty(gameTypeName) || !currentPlayerId.HasValue) { _logger.LogWarning("Cannot process throw: missing game type or player"); return Task.CompletedTask; } // Get game logic service (may be null if game type not registered) IGameLogicService? gameLogicService = null; try { gameLogicService = _gameRegistry.GetLogicService(gameTypeName); } catch (Exception ex) { _logger.LogWarning(ex, "Could not resolve game logic service for {GameType}", gameTypeName); } // Calculate new throw panel state with incremented counters var newThrowPanel = action.AfterThrowState with { ThrowCounterPerRound = action.AfterThrowState.ThrowCounterPerRound + 1, TotalThrowCounter = action.AfterThrowState.TotalThrowCounter + 1, BellValue = false // Reset bell after throw }; // Check if round is complete BEFORE applying pin reset bool isRoundComplete = newThrowPanel.ThrowCounterPerRound >= newThrowPanel.ThrowsPerRound; // Create AfterThrowState for game logic with updated counters var beforeSnapshot = CreateThrowPanelSnapshot(action.BeforeThrowState); var afterSnapshot = CreateThrowPanelSnapshot(newThrowPanel); var afterThrowState = afterSnapshot.CreateAfterThrowState(beforeSnapshot, currentPlayerId.Value, action.IsGutter); // Default values bool shouldRotatePlayer = false; Guid? nextPlayerId = null; PinStatus[]? pinPattern = null; bool isGameOver = false; Guid? winnerId = null; object? updatedGameModel = state.GameModel; // Call ProcessThrow if game logic service is available if (gameLogicService != null && state.GameModel != null) { try { var (updatedModel, throwResult) = gameLogicService.ProcessThrow(state.GameModel, afterThrowState); updatedGameModel = updatedModel; shouldRotatePlayer = throwResult.ShouldRotatePlayer; isGameOver = throwResult.IsGameOver; winnerId = throwResult.WinnerId; // Check for overrides from game logic if (throwResult.Overrides != null) { nextPlayerId = throwResult.Overrides.NextPlayerId; pinPattern = throwResult.Overrides.PinPattern; // If game logic provides pin pattern, use it if (pinPattern != null && pinPattern.Length == 9) { newThrowPanel = ApplyPinPattern(newThrowPanel, pinPattern); } else if (!throwResult.Overrides.SkipStandardPinReset) { // Apply standard pin reset behavior newThrowPanel = ApplyStandardPinReset(newThrowPanel); } } else { // No overrides - apply standard pin reset behavior newThrowPanel = ApplyStandardPinReset(newThrowPanel); } _logger.LogDebug( "ProcessThrow completed: pins={Pins}, rotate={Rotate}, gameOver={GameOver}", afterThrowState.PinsKnocked, shouldRotatePlayer, isGameOver); } catch (Exception ex) { _logger.LogError(ex, "Error in ProcessThrow for game type {GameType}", gameTypeName); // Continue with standard behavior on error newThrowPanel = ApplyStandardPinReset(newThrowPanel); shouldRotatePlayer = isRoundComplete; } } else { // No game logic service - use standard behavior newThrowPanel = ApplyStandardPinReset(newThrowPanel); shouldRotatePlayer = isRoundComplete; } // Dispatch result action dispatcher.Dispatch(new ProcessThrowResultAction( newThrowPanel, updatedGameModel, shouldRotatePlayer, nextPlayerId, pinPattern, isGameOver, winnerId)); // Debounce save operations to avoid excessive DB writes _saveDebounceTimer?.Dispose(); _saveDebounceTimer = new Timer( async _ => await DebouncedSaveAsync(dispatcher), null, SaveDebounceMs, Timeout.Infinite); return Task.CompletedTask; } private static ThrowPanelSnapshot CreateThrowPanelSnapshot(ThrowPanelState state) { return ThrowPanelSnapshot.FromPins( state.GetPins(), state.ThrowsPerRound, state.ThrowCounterPerRound, state.TotalThrowCounter, state.ThrowMode, state.BellValue); } private static ThrowPanelState ApplyPinPattern(ThrowPanelState state, PinStatus[] pattern) { return state with { Pin1 = pattern[0], Pin2 = pattern[1], Pin3 = pattern[2], Pin4 = pattern[3], Pin5 = pattern[4], Pin6 = pattern[5], Pin7 = pattern[6], Pin8 = pattern[7], Pin9 = pattern[8] }; } private static ThrowPanelState ApplyStandardPinReset(ThrowPanelState state) { // Check if round is complete if (state.ThrowCounterPerRound >= state.ThrowsPerRound) { // Reset throw counter and all pins at end of round var resetState = state with { ThrowCounterPerRound = 0 }; return resetState.ResetPins(); } // Within round - behavior depends on throw mode if (state.ThrowMode == ThrowMode.Reposition) { // Reposition: reset pins after each throw return state.ResetPins(); } if (state.ThrowMode == ThrowMode.Decrease) { // Decrease: mark fallen as disabled, check for all-down reset var newState = state.MarkFallenAsDisabled(); if (newState.AllPinsDown()) { return newState.ResetPins(); } return newState; } return state; } /// /// Handles UndoThrowSuccessAction - triggers debounced save after undo. /// [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.ThrowPanelAfter.CountFallenPins(), stateAfterReducer.ThrowPanelAfter.TotalThrowCounter, stateAfterReducer.UndoStack.Count, stateAfterReducer.RedoStack.Count); // Save after undo as well _saveDebounceTimer?.Dispose(); _saveDebounceTimer = new Timer( async _ => await DebouncedSaveAsync(dispatcher), null, SaveDebounceMs, Timeout.Infinite); return Task.CompletedTask; } /// /// Handles UndoThrowAction - pops last snapshot from stack and restores state. /// [EffectMethod] public Task HandleUndoThrow(UndoThrowAction action, IDispatcher dispatcher) { var state = _gameState.Value; if (state.UndoStack.Count == 0) { _logger.LogWarning("Undo requested but undo stack is empty"); dispatcher.Dispatch(new UndoThrowFailureAction("Keine Würfe zum Rückgängigmachen vorhanden")); return Task.CompletedTask; } var lastSnapshot = state.UndoStack[^1]; _logger.LogInformation( "UNDO: Stack={StackSize}, Current pins fallen={CurrentFallen}, Snapshot pins fallen={SnapshotFallen}, Current total={CurrentTotal}, Snapshot total={SnapshotTotal}", state.UndoStack.Count, state.ThrowPanelAfter.CountFallenPins(), lastSnapshot.ThrowPanelAfter.CountFallenPins(), state.ThrowPanelAfter.TotalThrowCounter, lastSnapshot.ThrowPanelAfter.TotalThrowCounter); dispatcher.Dispatch(new UndoThrowSuccessAction( lastSnapshot.ThrowPanelAfter, lastSnapshot.Participants, lastSnapshot.GameModel)); return Task.CompletedTask; } /// /// Handles RedoThrowAction - pops last snapshot from redo stack and restores state. /// [EffectMethod] public Task HandleRedoThrow(RedoThrowAction action, IDispatcher dispatcher) { var state = _gameState.Value; if (state.RedoStack.Count == 0) { _logger.LogWarning("Redo requested but redo stack is empty"); dispatcher.Dispatch(new RedoThrowFailureAction("Keine Würfe zum Wiederherstellen vorhanden")); return Task.CompletedTask; } var lastSnapshot = state.RedoStack[^1]; _logger.LogDebug( "Redoing throw. Stack size before: {StackSize}, Restoring to throw counter: {ThrowCounter}", state.RedoStack.Count, lastSnapshot.ThrowPanelAfter.TotalThrowCounter); dispatcher.Dispatch(new RedoThrowSuccessAction( lastSnapshot.ThrowPanelAfter, lastSnapshot.Participants, lastSnapshot.GameModel)); return Task.CompletedTask; } /// /// Handles RedoThrowSuccessAction - triggers debounced save after redo. /// [EffectMethod] public Task HandleRedoThrowSuccess(RedoThrowSuccessAction action, IDispatcher dispatcher) { // Save after redo _saveDebounceTimer?.Dispose(); _saveDebounceTimer = new Timer( async _ => await DebouncedSaveAsync(dispatcher), null, SaveDebounceMs, Timeout.Infinite); return Task.CompletedTask; } /// /// Handles SaveGameStateAction - explicit save to database. /// [EffectMethod] public async Task HandleSaveGameState(SaveGameStateAction action, IDispatcher dispatcher) { var state = _gameState.Value; if (!state.ActiveGameId.HasValue) { dispatcher.Dispatch(new SaveGameStateFailureAction("Kein aktives Spiel")); return; } var success = await SaveCurrentStateAsync(state.ActiveGameId.Value); if (success) { dispatcher.Dispatch(new SaveGameStateSuccessAction()); } else { dispatcher.Dispatch(new ConcurrencyConflictAction(state.ActiveGameId.Value)); } } private async Task DebouncedSaveAsync(IDispatcher dispatcher) { var state = _gameState.Value; if (!state.ActiveGameId.HasValue || !state.IsGameActive) return; var success = await SaveCurrentStateAsync(state.ActiveGameId.Value); if (success) { dispatcher.Dispatch(new RecordThrowSuccessAction()); // Broadcast state update via SignalR var stateDto = CreateSerializationDto(state); await _hubService.BroadcastGameStateAsync(state.ActiveGameId.Value, stateDto); } else { dispatcher.Dispatch(new ConcurrencyConflictAction(state.ActiveGameId.Value)); } } private static GameStateSerializationDto CreateSerializationDto(GameState state) { return new GameStateSerializationDto { ThrowPanelBefore = MapThrowPanelToDto(state.ThrowPanelBefore), ThrowPanelAfter = MapThrowPanelToDto(state.ThrowPanelAfter), Participants = new ParticipantsStateDto { PlayerIds = state.Participants.PlayerIds, CurrentPlayerIndex = state.Participants.CurrentPlayerIndex, Mode = (int)state.Participants.Mode }, GameModel = state.GameModel != null ? System.Text.Json.JsonSerializer.SerializeToElement(state.GameModel, GameStateSerializationDto.JsonOptions) : null, Setup = state.Setup != null ? System.Text.Json.JsonSerializer.SerializeToElement(state.Setup, GameStateSerializationDto.JsonOptions) : null, UndoStack = state.UndoStack.Select(s => new GameSnapshotDto { ThrowPanelAfter = MapThrowPanelToDto(s.ThrowPanelAfter), Participants = new ParticipantsStateDto { PlayerIds = s.Participants.PlayerIds, CurrentPlayerIndex = s.Participants.CurrentPlayerIndex, Mode = (int)s.Participants.Mode }, GameModel = s.GameModel != null ? System.Text.Json.JsonSerializer.SerializeToElement(s.GameModel, GameStateSerializationDto.JsonOptions) : null }).ToList(), RedoStack = state.RedoStack.Select(s => new GameSnapshotDto { ThrowPanelAfter = MapThrowPanelToDto(s.ThrowPanelAfter), Participants = new ParticipantsStateDto { PlayerIds = s.Participants.PlayerIds, CurrentPlayerIndex = s.Participants.CurrentPlayerIndex, Mode = (int)s.Participants.Mode }, GameModel = s.GameModel != null ? System.Text.Json.JsonSerializer.SerializeToElement(s.GameModel, GameStateSerializationDto.JsonOptions) : null }).ToList() }; } private async Task SaveCurrentStateAsync(Guid gameId) { var state = _gameState.Value; var stateDto = CreateSerializationDto(state); var json = JsonSerializer.Serialize(stateDto, GameStateSerializationDto.JsonOptions); return await _persistenceService.SaveGameStateAsync(gameId, json); } private static ThrowPanelStateDto MapThrowPanelToDto(ThrowPanelState state) => new() { IsStarted = state.IsStarted, Pins = [ (int)state.Pin1, (int)state.Pin2, (int)state.Pin3, (int)state.Pin4, (int)state.Pin5, (int)state.Pin6, (int)state.Pin7, (int)state.Pin8, (int)state.Pin9 ], ThrowsPerRound = state.ThrowsPerRound, ThrowCounterPerRound = state.ThrowCounterPerRound, ThrowMode = (int)state.ThrowMode, TotalThrowCounter = state.TotalThrowCounter, BellValue = state.BellValue }; private static ThrowPanelState MapDtoToThrowPanel(ThrowPanelStateDto dto) => new() { IsStarted = dto.IsStarted, Pin1 = (PinStatus)dto.Pins[0], Pin2 = (PinStatus)dto.Pins[1], Pin3 = (PinStatus)dto.Pins[2], Pin4 = (PinStatus)dto.Pins[3], Pin5 = (PinStatus)dto.Pins[4], Pin6 = (PinStatus)dto.Pins[5], Pin7 = (PinStatus)dto.Pins[6], Pin8 = (PinStatus)dto.Pins[7], Pin9 = (PinStatus)dto.Pins[8], ThrowsPerRound = dto.ThrowsPerRound, ThrowCounterPerRound = dto.ThrowCounterPerRound, ThrowMode = (ThrowMode)dto.ThrowMode, TotalThrowCounter = dto.TotalThrowCounter, BellValue = dto.BellValue }; private static ParticipantsState MapDtoToParticipants(ParticipantsStateDto dto) => new() { PlayerIds = dto.PlayerIds, CurrentPlayerIndex = dto.CurrentPlayerIndex, Mode = (ParticipantsMode)dto.Mode }; }