KoogleApp/src/Koogle.Web/Store/GameState/GameEffects.cs

1234 lines
45 KiB
C#

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;
/// <summary>
/// Side effects for game state management.
/// Handles async operations like persistence, state recovery, and SignalR broadcasts.
/// </summary>
public class GameEffects
{
private readonly ILogger<GameEffects> _logger;
private readonly IState<GameState> _gameState;
private readonly IGamePersistenceService _persistenceService;
private readonly ICurrentClubContext _clubContext;
private readonly GameDefinitionRegistry _gameRegistry;
private readonly GameHubService _hubService;
private readonly IOptions<AppSettings> _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;
/// <summary>
/// Initializes a new instance of the GameEffects class.
/// </summary>
public GameEffects(
ILogger<GameEffects> logger,
IState<GameState> gameState,
IGamePersistenceService persistenceService,
ICurrentClubContext clubContext,
GameDefinitionRegistry gameRegistry,
GameHubService hubService,
IOptions<AppSettings> 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;
}
/// <summary>
/// Handles StartGameAction - creates game entity in database.
/// </summary>
[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}"));
}
}
/// <summary>
/// Handles EndGameAction - updates game status in database.
/// </summary>
[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}"));
}
}
/// <summary>
/// Handles LoadActiveGameAction - loads active game from database for recovery.
/// </summary>
[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<GameStateSerializationDto>(
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<IGameSetupModel>(
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}"));
}
}
/// <summary>
/// Handles LoadCompletedGamesAction - loads completed games list from database.
/// </summary>
[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}"));
}
}
/// <summary>
/// Handles RecordThrowAction - calls ProcessThrow and applies game logic.
/// Also fires expense triggers for special events (Gutter, Strike, etc.).
/// </summary>
[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<TriggerEvent> 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);
}
/// <summary>
/// Handles ExecuteGameActionAction - executes game-specific action via IGameLogicService.
/// </summary>
[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;
}
/// <summary>
/// Handles UndoThrowSuccessAction - triggers debounced save after undo.
/// </summary>
[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;
}
/// <summary>
/// Handles UndoThrowAction - pops last snapshot from stack and restores state.
/// </summary>
[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;
}
/// <summary>
/// Handles RedoThrowAction - pops last snapshot from redo stack and restores state.
/// </summary>
[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;
}
/// <summary>
/// Handles RedoThrowSuccessAction - triggers debounced save after redo.
/// </summary>
[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;
}
/// <summary>
/// Handles SaveGameStateAction - explicit save to database.
/// </summary>
[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<bool> 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
};
/// <summary>
/// Fires expense triggers based on throw events (Gutter, Strike, Circle, etc.).
/// Dispatches TriggerExpensesCreatedAction to update UI.
/// </summary>
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<PersonExpenseDto>();
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
}
}
/// <summary>
/// Triggers GIF playback for special throw events (Strike, Circle, Gutter, Bell).
/// Selects a random GIF for the event and broadcasts to all clients.
/// </summary>
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
}
}
/// <summary>
/// Fires game-specific triggers returned by IGameLogicService.ProcessThrow.
/// These are separate from standard throw triggers (Gutter, Strike, etc.).
/// </summary>
private async Task FireGameLogicTriggersAsync(
Guid dayId,
Guid gameId,
List<Guid> allParticipantIds,
IReadOnlyList<TriggerEvent> triggers,
IDispatcher dispatcher)
{
var allCreatedExpenses = new List<PersonExpenseDto>();
try
{
foreach (var trigger in triggers)
{
// Parse trigger type
if (!Enum.TryParse<ExpenseTriggerType>(trigger.TriggerType, out var triggerType))
{
_logger.LogWarning("Unknown trigger type: {TriggerType}", trigger.TriggerType);
continue;
}
List<PersonExpenseDto> 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
}
}
/// <summary>
/// Records player statistics for the current throw.
/// </summary>
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
}
}
/// <summary>
/// Extracts PlayerOrder from game model if available (e.g., DeathBox randomizes player order).
/// Returns null if the model doesn't have a PlayerOrder property.
/// </summary>
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<Guid[]>(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;
}
}