From f297317b719c7491b6b4ce5f4950976dbb0f96ff Mon Sep 17 00:00:00 2001 From: beo3000 Date: Sat, 27 Dec 2025 14:39:15 +0100 Subject: [PATCH] call gamelogic --- src/Koogle.Application/Games/GameProgress.cs | 32 ++++ .../Components/Game/GameInputPanel.razor | 77 ++++---- src/Koogle.Web/Store/GameState/GameActions.cs | 23 ++- src/Koogle.Web/Store/GameState/GameEffects.cs | 175 +++++++++++++++++- .../Store/GameState/GameReducers.cs | 39 +++- 5 files changed, 302 insertions(+), 44 deletions(-) diff --git a/src/Koogle.Application/Games/GameProgress.cs b/src/Koogle.Application/Games/GameProgress.cs index 98ab5b3..14335f6 100644 --- a/src/Koogle.Application/Games/GameProgress.cs +++ b/src/Koogle.Application/Games/GameProgress.cs @@ -196,6 +196,38 @@ public record ThrowResult /// Trigger events that should fire (e.g., for penalties). /// public IReadOnlyList Triggers { get; init; } = []; + + /// + /// Optional overrides for standard lane behavior. + /// If null, standard behavior applies (pins reset based on ThrowMode after round). + /// + public GameLogicOverrides? Overrides { get; init; } +} + +/// +/// Optional overrides for standard lane behavior. +/// Game logic can use these to control next player or pin pattern. +/// +public record GameLogicOverrides +{ + /// + /// Override the next player. If set, this player will throw next instead of + /// following the standard rotation. + /// + public Guid? NextPlayerId { get; init; } + + /// + /// Override the pin pattern for the next throw. If set, these pin statuses + /// will be used instead of the standard reset behavior. + /// Array of 9 PinStatus values (index 0 = pin 1, etc.). + /// + public PinStatus[]? PinPattern { get; init; } + + /// + /// If true, skip the standard pin reset after round completion. + /// Use this when PinPattern provides a custom configuration. + /// + public bool SkipStandardPinReset { get; init; } } /// diff --git a/src/Koogle.Web/Components/Game/GameInputPanel.razor b/src/Koogle.Web/Components/Game/GameInputPanel.razor index 12fd88f..423a49f 100644 --- a/src/Koogle.Web/Components/Game/GameInputPanel.razor +++ b/src/Koogle.Web/Components/Game/GameInputPanel.razor @@ -2,6 +2,7 @@ @using Koogle.Web.Store.GameState @using Koogle.Web.Components.Game @inherits FluxorComponent +@implements IDisposable @inject IState GameState @inject IDispatcher Dispatcher @@ -163,9 +164,37 @@ private int? _selectedNumber; private bool _hasModifiedPins; + private ThrowPanelState? _beforeThrowState; private bool IsInteractive => GameState.Value.IsGameActive && !GameState.Value.IsLoading; + protected override void OnInitialized() + { + base.OnInitialized(); + GameState.StateChanged += OnGameStateChanged; + // Store initial state for before/after comparison + CaptureBeforeThrowState(); + } + + private void OnGameStateChanged(object? sender, EventArgs e) + { + // After throw is processed and pins are reset, capture new before state + if (!_hasModifiedPins) + { + CaptureBeforeThrowState(); + } + } + + private void CaptureBeforeThrowState() + { + _beforeThrowState = GameState.Value.ThrowPanel; + } + + public void Dispose() + { + GameState.StateChanged -= OnGameStateChanged; + } + // Throw can always be confirmed - no pins marked = "Kein Holz" (0 fallen) private bool CanConfirmThrow => true; @@ -229,59 +258,31 @@ private async Task ConfirmThrow(bool isGutter, bool isLeftGutter) { - var fallenPins = GameState.Value.ThrowPanel.CountFallenPins(); - var bellValue = GameState.Value.ThrowPanel.BellValue; + var currentThrowPanel = GameState.Value.ThrowPanel; + var fallenPins = currentThrowPanel.CountFallenPins(); + var bellValue = currentThrowPanel.BellValue; - // Create throw result + // Create throw result for UI callback var result = new ThrowResult { FallenPins = fallenPins, IsGutter = isGutter, IsLeftGutter = isLeftGutter, BellValue = bellValue, - PinStates = GameState.Value.ThrowPanel.GetPins() + PinStates = currentThrowPanel.GetPins() }; - // Calculate new throw panel state - var newThrowPanel = GameState.Value.ThrowPanel with - { - ThrowCounterPerRound = GameState.Value.ThrowPanel.ThrowCounterPerRound + 1, - TotalThrowCounter = GameState.Value.ThrowPanel.TotalThrowCounter + 1, - BellValue = false // Reset bell after throw - }; + // Get before state (captured at start of input session) + var beforeState = _beforeThrowState ?? currentThrowPanel; - // Check if round is complete - if (newThrowPanel.ThrowCounterPerRound >= newThrowPanel.ThrowsPerRound) - { - newThrowPanel = newThrowPanel with { ThrowCounterPerRound = 0 }; - // Reset all pins at end of round (both modes) - newThrowPanel = newThrowPanel.ResetPins(); - } - else if (newThrowPanel.ThrowMode == ThrowMode.Reposition) - { - // In Reposition mode, always reset pins after each throw - newThrowPanel = newThrowPanel.ResetPins(); - } - else if (newThrowPanel.ThrowMode == ThrowMode.Decrease) - { - // In Decrease mode: Mark fallen pins as disabled (not available for next throw) - newThrowPanel = newThrowPanel.MarkFallenAsDisabled(); - - // If all pins are down, reset all pins for a fresh set - if (newThrowPanel.AllPinsDown()) - { - newThrowPanel = newThrowPanel.ResetPins(); - } - } - - // Dispatch record throw action - Dispatcher.Dispatch(new RecordThrowAction(newThrowPanel, GameState.Value.GameModel)); + // Dispatch record throw action - game logic will handle pin reset and player rotation + Dispatcher.Dispatch(new RecordThrowAction(beforeState, currentThrowPanel)); // Reset local state _selectedNumber = null; _hasModifiedPins = false; - // Notify parent + // Notify parent (for timer/tab switching) await OnThrowCompleted.InvokeAsync(result); } diff --git a/src/Koogle.Web/Store/GameState/GameActions.cs b/src/Koogle.Web/Store/GameState/GameActions.cs index ae20344..258a989 100644 --- a/src/Koogle.Web/Store/GameState/GameActions.cs +++ b/src/Koogle.Web/Store/GameState/GameActions.cs @@ -96,7 +96,28 @@ public record LoadCompletedGamesFailureAction(string Error); /// /// Action to record a throw with pin results. /// -public record RecordThrowAction(ThrowPanelState NewThrowPanelState, object? UpdatedGameModel); +/// State before the throw was made. +/// State after the throw was made. +public record RecordThrowAction(ThrowPanelState BeforeThrowState, ThrowPanelState AfterThrowState); + +/// +/// Action dispatched after ProcessThrow completes with game logic result. +/// +/// Updated throw panel state. +/// Updated game model from ProcessThrow. +/// Whether to rotate to next player. +/// Override: specific player to set as current. +/// Override: custom pin pattern for next throw. +/// Whether the game has ended. +/// Winner if game is over. +public record ProcessThrowResultAction( + ThrowPanelState NewThrowPanelState, + object? UpdatedGameModel, + bool ShouldRotatePlayer, + Guid? NextPlayerId, + PinStatus[]? PinPattern, + bool IsGameOver, + Guid? WinnerId); /// /// Action dispatched when throw is recorded successfully. diff --git a/src/Koogle.Web/Store/GameState/GameEffects.cs b/src/Koogle.Web/Store/GameState/GameEffects.cs index da7e391..260754e 100644 --- a/src/Koogle.Web/Store/GameState/GameEffects.cs +++ b/src/Koogle.Web/Store/GameState/GameEffects.cs @@ -17,6 +17,7 @@ public class GameEffects private readonly IState _gameState; private readonly IGamePersistenceService _persistenceService; private readonly ICurrentClubContext _clubContext; + private readonly GameDefinitionRegistry _gameRegistry; // Debounce timer for save operations private Timer? _saveDebounceTimer; @@ -29,12 +30,14 @@ public class GameEffects ILogger logger, IState gameState, IGamePersistenceService persistenceService, - ICurrentClubContext clubContext) + ICurrentClubContext clubContext, + GameDefinitionRegistry gameRegistry) { _logger = logger; _gameState = gameState; _persistenceService = persistenceService; _clubContext = clubContext; + _gameRegistry = gameRegistry; } /// @@ -246,11 +249,121 @@ public class GameEffects } /// - /// Handles RecordThrowAction - triggers debounced save to database. + /// Handles RecordThrowAction - calls ProcessThrow and applies game logic. /// [EffectMethod] public Task HandleRecordThrow(RecordThrowAction action, IDispatcher dispatcher) { + var state = _gameState.Value; + var gameTypeName = state.GameTypeName; + var currentPlayerId = state.Participants.CurrentPlayerId; + + if (string.IsNullOrEmpty(gameTypeName) || !currentPlayerId.HasValue) + { + _logger.LogWarning("Cannot process throw: missing game type or player"); + return Task.CompletedTask; + } + + // Get game logic service (may be null if game type not registered) + IGameLogicService? gameLogicService = null; + try + { + gameLogicService = _gameRegistry.GetLogicService(gameTypeName); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Could not resolve game logic service for {GameType}", gameTypeName); + } + + // Create AfterThrowState for game logic + var beforeSnapshot = CreateThrowPanelSnapshot(action.BeforeThrowState); + var afterSnapshot = CreateThrowPanelSnapshot(action.AfterThrowState); + var afterThrowState = afterSnapshot.CreateAfterThrowState(beforeSnapshot, currentPlayerId.Value); + + // Calculate new throw panel state with standard lane behavior + var newThrowPanel = action.AfterThrowState with + { + ThrowCounterPerRound = action.AfterThrowState.ThrowCounterPerRound + 1, + TotalThrowCounter = action.AfterThrowState.TotalThrowCounter + 1, + BellValue = false // Reset bell after throw + }; + + // 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); + } + } + else + { + // No game logic service - use standard behavior + newThrowPanel = ApplyStandardPinReset(newThrowPanel); + + // Standard rotation: rotate after round is complete + shouldRotatePlayer = newThrowPanel.ThrowCounterPerRound >= newThrowPanel.ThrowsPerRound; + if (shouldRotatePlayer) + { + newThrowPanel = newThrowPanel with { ThrowCounterPerRound = 0 }; + } + } + + // 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( @@ -262,6 +375,64 @@ public class GameEffects return Task.CompletedTask; } + private static ThrowPanelSnapshot CreateThrowPanelSnapshot(ThrowPanelState state) + { + return ThrowPanelSnapshot.FromPins( + state.GetPins(), + state.ThrowsPerRound, + state.ThrowCounterPerRound, + state.TotalThrowCounter, + state.ThrowMode, + state.BellValue); + } + + private static ThrowPanelState ApplyPinPattern(ThrowPanelState state, PinStatus[] pattern) + { + return state with + { + Pin1 = pattern[0], + Pin2 = pattern[1], + Pin3 = pattern[2], + Pin4 = pattern[3], + Pin5 = pattern[4], + Pin6 = pattern[5], + Pin7 = pattern[6], + Pin8 = pattern[7], + Pin9 = pattern[8] + }; + } + + private static ThrowPanelState ApplyStandardPinReset(ThrowPanelState state) + { + // Check if round is complete + if (state.ThrowCounterPerRound >= state.ThrowsPerRound) + { + // Reset throw counter and all pins at end of round + var resetState = state with { ThrowCounterPerRound = 0 }; + return resetState.ResetPins(); + } + + // Within round - behavior depends on throw mode + if (state.ThrowMode == ThrowMode.Reposition) + { + // Reposition: reset pins after each throw + return state.ResetPins(); + } + + if (state.ThrowMode == ThrowMode.Decrease) + { + // Decrease: mark fallen as disabled, check for all-down reset + var newState = state.MarkFallenAsDisabled(); + if (newState.AllPinsDown()) + { + return newState.ResetPins(); + } + return newState; + } + + return state; + } + /// /// Handles UndoThrowSuccessAction - triggers debounced save after undo. /// diff --git a/src/Koogle.Web/Store/GameState/GameReducers.cs b/src/Koogle.Web/Store/GameState/GameReducers.cs index 54af5fb..34ebede 100644 --- a/src/Koogle.Web/Store/GameState/GameReducers.cs +++ b/src/Koogle.Web/Store/GameState/GameReducers.cs @@ -181,12 +181,12 @@ public static class GameReducers // Throw Reducers /// - /// Handles RecordThrowAction - updates state and adds snapshot to undo stack. + /// Handles RecordThrowAction - creates undo snapshot before effect processes throw. /// [ReducerMethod] public static GameState OnRecordThrow(GameState state, RecordThrowAction action) { - // Create snapshot of current state before applying throw + // Create snapshot of current state before throw is processed var snapshot = new GameSnapshot { ThrowPanel = state.ThrowPanel, @@ -195,12 +195,45 @@ public static class GameReducers }; return state with + { + UndoStack = state.UndoStack.Add(snapshot), + IsLoading = true + }; + } + + /// + /// Handles ProcessThrowResultAction - applies game logic result with optional overrides. + /// + [ReducerMethod] + public static GameState OnProcessThrowResult(GameState state, ProcessThrowResultAction action) + { + var newState = state with { ThrowPanel = action.NewThrowPanelState, GameModel = action.UpdatedGameModel, - UndoStack = state.UndoStack.Add(snapshot), + IsLoading = false, IsSaving = true }; + + // Apply player rotation or override + if (action.NextPlayerId.HasValue) + { + // Game logic specifies exact next player + newState = newState with + { + Participants = newState.Participants.SetCurrentPlayer(action.NextPlayerId.Value) + }; + } + else if (action.ShouldRotatePlayer) + { + // Standard rotation to next player + newState = newState with + { + Participants = newState.Participants.NextPlayer() + }; + } + + return newState; } ///