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

723 lines
27 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.Web.Hubs;
using Koogle.Web.Services;
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;
// 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)
{
_logger = logger;
_gameState = gameState;
_persistenceService = persistenceService;
_clubContext = clubContext;
_gameRegistry = gameRegistry;
_hubService = hubService;
}
/// <summary>
/// Handles StartGameAction - creates game entity in database.
/// </summary>
[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}"));
}
}
/// <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.
/// </summary>
[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;
}
/// <summary>
/// Handles UndoThrowSuccessAction - triggers debounced save after undo.
/// </summary>
[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;
}
/// <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));
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));
return Task.CompletedTask;
}
/// <summary>
/// Handles RedoThrowSuccessAction - triggers debounced save after redo.
/// </summary>
[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;
}
/// <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
};
}