1234 lines
45 KiB
C#
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;
|
|
}
|
|
}
|