using System.Text.Json; using Fluxor; using Koogle.Application.DTOs; using Koogle.Application.Games; using Koogle.Application.Interfaces; using Koogle.Domain.Enums; using Koogle.Infrastructure.Configuration; using Koogle.Web.Hubs; using Koogle.Web.Services; using Koogle.Web.Store.DayState; using Koogle.Web.Store.GifState; using Microsoft.Extensions.Options; 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; private readonly IOptions _appSettings; private readonly IGameEventService _gameEventService; private readonly IPlayerStatisticsService _playerStatisticsService; private readonly IClubGifService _gifService; private readonly IDispatcher _dispatcher; // 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, IOptions appSettings, IGameEventService gameEventService, IPlayerStatisticsService playerStatisticsService, IClubGifService gifService, IDispatcher dispatcher) { _logger = logger; _gameState = gameState; _persistenceService = persistenceService; _clubContext = clubContext; _gameRegistry = gameRegistry; _hubService = hubService; _appSettings = appSettings; _gameEventService = gameEventService; _playerStatisticsService = playerStatisticsService; _gifService = gifService; _dispatcher = dispatcher; } /// /// Handles StartGameAction - creates game entity in database. /// [EffectMethod] public async Task HandleStartGame(StartGameAction action, IDispatcher dispatcher) { try { // Use PlayerOrder from GameModel if available (e.g., DeathBox randomizes order) var playerIds = action.PlayerIds; if (action.InitialGameModel != null) { playerIds = ExtractPlayerOrder(action.InitialGameModel) ?? playerIds; } 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 = playerIds, // Use randomized order 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 = playerIds, // Use randomized order 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 = 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. /// Also fires expense triggers for special events (Gutter, Strike, etc.). /// [EffectMethod] public async Task HandleRecordThrow(RecordThrowAction action, IDispatcher dispatcher) { var state = _gameState.Value; var gameTypeName = state.GameTypeName; var currentPlayerId = state.Participants.CurrentPlayerId; // Block input if game is already over if (state.IsGameOver) { _logger.LogWarning("Cannot process throw: game is already over"); return; } if (string.IsNullOrEmpty(gameTypeName) || !currentPlayerId.HasValue) { _logger.LogWarning("Cannot process throw: missing game type or player"); return; } // 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; IReadOnlyList gameLogicTriggers = []; // 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; gameLogicTriggers = throwResult.Triggers; // 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)); // Fire expense triggers for special throw events await FireThrowTriggersAsync(state, currentPlayerId.Value, action, afterThrowState, dispatcher); // Record player statistics (game-type independent) await RecordPlayerStatisticsAsync(state, currentPlayerId.Value, afterThrowState); // Fire game-specific triggers (e.g., ExpensePoint from Scheiss-Spiel) if (gameLogicTriggers.Count > 0 && state.DayId.HasValue && state.ActiveGameId.HasValue) { await FireGameLogicTriggersAsync( state.DayId.Value, state.ActiveGameId.Value, state.Participants.PlayerIds.ToList(), gameLogicTriggers, dispatcher); } // Broadcast throw immediately to other clients (before debounced save) if (state.ActiveGameId.HasValue) { await _hubService.BroadcastThrowAsync( state.ActiveGameId.Value, currentPlayerId.Value, afterThrowState.PinsKnocked); } // If game is over, skip save (user must confirm end via UI) if (isGameOver) { return; } // Debounce save operations to avoid excessive DB writes _saveDebounceTimer?.Dispose(); _saveDebounceTimer = new Timer( async _ => await DebouncedSaveAsync(dispatcher), null, SaveDebounceMs, Timeout.Infinite); } /// /// Handles ExecuteGameActionAction - executes game-specific action via IGameLogicService. /// [EffectMethod] public async Task HandleExecuteGameAction(ExecuteGameActionAction action, IDispatcher dispatcher) { var state = _gameState.Value; var gameTypeName = state.GameTypeName; var currentPlayerId = state.Participants.CurrentPlayerId; // Block input if game is already over if (state.IsGameOver) { dispatcher.Dispatch(new ExecuteGameActionFailureAction("Cannot execute action: game is already over")); return; } if (string.IsNullOrEmpty(gameTypeName) || !currentPlayerId.HasValue || state.GameModel == null) { dispatcher.Dispatch(new ExecuteGameActionFailureAction("Cannot execute action: missing game state")); return; } try { var gameLogicService = _gameRegistry.GetLogicService(gameTypeName); var result = gameLogicService.ExecuteAction( state.GameModel, action.ActionId, currentPlayerId.Value, action.Parameters); if (result.Success) { dispatcher.Dispatch(new ExecuteGameActionSuccessAction( result.UpdatedModel, result.ShouldRotatePlayer, result.IsGameOver, result.WinnerId)); _logger.LogDebug( "Game action executed: {ActionId}, rotate={Rotate}, gameOver={GameOver}", action.ActionId, result.ShouldRotatePlayer, result.IsGameOver); // Fire game-specific triggers from action result if (result.Triggers.Count > 0 && state.DayId.HasValue && state.ActiveGameId.HasValue) { await FireGameLogicTriggersAsync( state.DayId.Value, state.ActiveGameId.Value, state.Participants.PlayerIds.ToList(), result.Triggers, dispatcher); } // If game is over, skip save (user must confirm end via UI) if (result.IsGameOver) { return; } // Debounce save operations _saveDebounceTimer?.Dispose(); _saveDebounceTimer = new Timer( async _ => await DebouncedSaveAsync(dispatcher), null, SaveDebounceMs, Timeout.Infinite); } else { dispatcher.Dispatch(new ExecuteGameActionFailureAction(result.Error ?? "Action failed")); } } catch (Exception ex) { _logger.LogError(ex, "Error executing game action: {ActionId}", action.ActionId); dispatcher.Dispatch(new ExecuteGameActionFailureAction($"Error: {ex.Message}")); } } 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.LogDebug( "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); if (_appSettings.Value.SaveOnUndoRedo) { // 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, _appSettings.Value.SaveOnUndoRedo)); 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, _appSettings.Value.SaveOnUndoRedo)); return Task.CompletedTask; } /// /// Handles RedoThrowSuccessAction - triggers debounced save after redo. /// [EffectMethod] public Task HandleRedoThrowSuccess(RedoThrowSuccessAction action, IDispatcher dispatcher) { var stateAfterReducer = _gameState.Value; _logger.LogDebug( "REDO 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); if (_appSettings.Value.SaveOnUndoRedo) { // 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 }; /// /// Fires expense triggers based on throw events (Gutter, Strike, Circle, etc.). /// Dispatches TriggerExpensesCreatedAction to update UI. /// private async Task FireThrowTriggersAsync( GameState state, Guid currentPlayerId, RecordThrowAction action, AfterThrowState afterThrowState, IDispatcher dispatcher) { // Skip if no day or game context if (!state.DayId.HasValue || !state.ActiveGameId.HasValue) { _logger.LogDebug("Skipping trigger fire: missing DayId or GameId"); return; } var dayId = state.DayId.Value; var gameId = state.ActiveGameId.Value; var allParticipantIds = state.Participants.PlayerIds.ToList(); var allCreatedExpenses = new List(); try { // Check for Gutter if (action.IsGutter) { // Check if this is the first throw of the round (Anwurf) bool isFirstThrow = afterThrowState.ThrowPanel.ThrowCounterPerRound == 1; var expenses = await _gameEventService.RegisterGutterAsync( currentPlayerId, dayId, gameId, allParticipantIds, isFirstThrow); allCreatedExpenses.AddRange(expenses); if (expenses.Count > 0) { _logger.LogInformation( "Gutter trigger fired: {Count} expenses assigned to player {PlayerId}", expenses.Count, currentPlayerId); } } // Check for Strike (alle Neune) if (afterThrowState.IsStrike) { var expenses = await _gameEventService.RegisterStrikeAsync( currentPlayerId, dayId, gameId, allParticipantIds); allCreatedExpenses.AddRange(expenses); if (expenses.Count > 0) { _logger.LogInformation( "Strike trigger fired: {Count} expenses assigned", expenses.Count); } } // Check for Circle (Kranz) if (afterThrowState.IsCircle) { var expenses = await _gameEventService.RegisterCircleAsync( currentPlayerId, dayId, gameId, allParticipantIds); allCreatedExpenses.AddRange(expenses); if (expenses.Count > 0) { _logger.LogInformation( "Circle trigger fired: {Count} expenses assigned", expenses.Count); } } // Check for No Wood (kein Holz) - 0 pins knocked but not a gutter if (afterThrowState.PinsKnocked == 0 && !action.IsGutter) { var expenses = await _gameEventService.RegisterNoWoodAsync( currentPlayerId, dayId, gameId, allParticipantIds); allCreatedExpenses.AddRange(expenses); if (expenses.Count > 0) { _logger.LogInformation( "NoWood trigger fired: {Count} expenses assigned", expenses.Count); } } // Check for Bell hit if (afterThrowState.ThrowPanel.BellValue) { var expenses = await _gameEventService.RegisterBellAsync( currentPlayerId, dayId, gameId, allParticipantIds); allCreatedExpenses.AddRange(expenses); if (expenses.Count > 0) { _logger.LogInformation( "Bell trigger fired: {Count} expenses assigned", expenses.Count); } } // Dispatch action to update UI with all created expenses if (allCreatedExpenses.Count > 0) { dispatcher.Dispatch(new DayState.TriggerExpensesCreatedAction(allCreatedExpenses)); } // Trigger GIF playback for special events await TriggerGifForEventsAsync(state, gameId, action, afterThrowState, dispatcher); } catch (Exception ex) { _logger.LogError(ex, "Error firing throw triggers for player {PlayerId}", currentPlayerId); // Don't fail the throw recording if trigger fails } } /// /// Triggers GIF playback for special throw events (Strike, Circle, Gutter, Bell). /// Selects a random GIF for the event and broadcasts to all clients. /// private async Task TriggerGifForEventsAsync( GameState state, Guid gameId, RecordThrowAction action, AfterThrowState afterThrowState, IDispatcher dispatcher) { var clubId = _clubContext.ClubId; ThrowEventType? triggeredEvent = null; // Determine which event occurred (priority order) if (afterThrowState.IsStrike) { triggeredEvent = ThrowEventType.Strike; } else if (afterThrowState.IsCircle) { triggeredEvent = ThrowEventType.Circle; } else if (afterThrowState.ThrowPanel.BellValue) { triggeredEvent = ThrowEventType.Bell; } else if (action.IsGutter) { triggeredEvent = ThrowEventType.Gutter; } else if (afterThrowState.PinsKnocked == 0) { triggeredEvent = ThrowEventType.NoWood; } if (!triggeredEvent.HasValue) { return; } try { var gif = await _gifService.SelectRandomForEventAsync(clubId, triggeredEvent.Value); if (gif != null) { // Dispatch action to show GIF locally dispatcher.Dispatch(new GifPlaybackStartedAction(gif, triggeredEvent.Value)); // Broadcast to other clients in the game await _hubService.BroadcastGifTriggeredAsync(gameId, gif, triggeredEvent.Value); _logger.LogInformation( "GIF triggered for event {EventType}: {GifName}", triggeredEvent.Value, gif.Name); } } catch (Exception ex) { _logger.LogError(ex, "Error triggering GIF for event {EventType}", triggeredEvent.Value); // Don't fail throw processing if GIF trigger fails } } /// /// Fires game-specific triggers returned by IGameLogicService.ProcessThrow. /// These are separate from standard throw triggers (Gutter, Strike, etc.). /// private async Task FireGameLogicTriggersAsync( Guid dayId, Guid gameId, List allParticipantIds, IReadOnlyList triggers, IDispatcher dispatcher) { var allCreatedExpenses = new List(); try { foreach (var trigger in triggers) { // Parse trigger type if (!Enum.TryParse(trigger.TriggerType, out var triggerType)) { _logger.LogWarning("Unknown trigger type: {TriggerType}", trigger.TriggerType); continue; } List expenses = []; switch (triggerType) { case ExpenseTriggerType.ExpensePoint: expenses = await _gameEventService.RegisterExpensePointsAsync( trigger.PersonId, dayId, gameId, allParticipantIds, trigger.Multiplier); break; case ExpenseTriggerType.Eliminated: expenses = await _gameEventService.RegisterEliminatedAsync( trigger.PersonId, dayId, gameId, allParticipantIds); break; default: _logger.LogDebug( "Trigger type {TriggerType} not handled in game logic triggers", triggerType); break; } allCreatedExpenses.AddRange(expenses); if (expenses.Count > 0) { _logger.LogInformation( "Game logic trigger fired: Type={TriggerType}, Person={PersonId}, Multiplier={Multiplier}, Expenses={Count}", triggerType, trigger.PersonId, trigger.Multiplier, expenses.Count); } } // Dispatch action to update UI with all created expenses if (allCreatedExpenses.Count > 0) { dispatcher.Dispatch(new DayState.TriggerExpensesCreatedAction(allCreatedExpenses)); } } catch (Exception ex) { _logger.LogError(ex, "Error firing game logic triggers"); // Don't fail the throw recording if trigger fails } } /// /// Records player statistics for the current throw. /// private async Task RecordPlayerStatisticsAsync( GameState state, Guid currentPlayerId, AfterThrowState afterThrowState) { if (!state.DayId.HasValue || !state.ActiveGameId.HasValue) return; try { await _playerStatisticsService.RecordThrowAsync( state.ActiveGameId.Value, currentPlayerId, _clubContext.ClubId, afterThrowState.PinsKnocked, afterThrowState.IsCleared, afterThrowState.IsGutter, afterThrowState.IsCircle, afterThrowState.IsStrike, afterThrowState.ThrowPanel.BellValue); } catch (Exception ex) { _logger.LogError(ex, "Error recording player statistics for player {PlayerId}", currentPlayerId); // Don't fail throw recording if stats fail } } /// /// Extracts PlayerOrder from game model if available (e.g., DeathBox randomizes player order). /// Returns null if the model doesn't have a PlayerOrder property. /// private static Guid[]? ExtractPlayerOrder(object? gameModel) { if (gameModel == null) return null; // Handle JsonElement (from deserialization) if (gameModel is JsonElement jsonElement) { if (jsonElement.TryGetProperty("PlayerOrder", out var playerOrderProp) || jsonElement.TryGetProperty("playerOrder", out playerOrderProp)) { try { return JsonSerializer.Deserialize(playerOrderProp.GetRawText()); } catch { return null; } } return null; } // Use reflection to check for PlayerOrder property var type = gameModel.GetType(); var property = type.GetProperty("PlayerOrder"); if (property != null && property.PropertyType == typeof(Guid[])) { return property.GetValue(gameModel) as Guid[]; } return null; } }