diff --git a/src/Koogle.Application/Games/GameActionTypes.cs b/src/Koogle.Application/Games/GameActionTypes.cs new file mode 100644 index 0000000..4f1170e --- /dev/null +++ b/src/Koogle.Application/Games/GameActionTypes.cs @@ -0,0 +1,106 @@ +namespace Koogle.Application.Games; + +/// +/// Describes a game-specific action available in the UI. +/// +public record GameActionDescriptor +{ + /// + /// Unique identifier for this action (e.g., "pass", "double_down"). + /// + public required string ActionId { get; init; } + + /// + /// Display label for the button/menu item. + /// + public required string Label { get; init; } + + /// + /// Optional icon (MudBlazor icon name, e.g., "Icons.Material.Filled.SkipNext"). + /// + public string? Icon { get; init; } + + /// + /// Color hint for the button (maps to MudBlazor Color: Primary, Secondary, Warning, Error, Success, Info). + /// + public string Color { get; init; } = "Primary"; + + /// + /// Whether action is enabled based on current game state. + /// + public bool IsEnabled { get; init; } = true; + + /// + /// Optional tooltip or helper text. + /// + public string? Tooltip { get; init; } + + /// + /// Display order for sorting (lower = first). + /// + public int Order { get; init; } = 100; +} + +/// +/// Result of executing a game action. +/// +public record GameActionResult +{ + /// + /// Whether action was successful. + /// + public bool Success { get; init; } + + /// + /// Updated game model after action. + /// + public object? UpdatedModel { get; init; } + + /// + /// Whether to rotate to next player. + /// + public bool ShouldRotatePlayer { get; init; } + + /// + /// Whether game has ended. + /// + public bool IsGameOver { get; init; } + + /// + /// Winner ID if game is over. + /// + public Guid? WinnerId { get; init; } + + /// + /// Error message if action failed. + /// + public string? Error { get; init; } + + /// + /// Trigger events (e.g., penalties). + /// + public IReadOnlyList Triggers { get; init; } = []; + + /// + /// Creates a failure result with error message. + /// + public static GameActionResult Failure(string error) => new() { Success = false, Error = error }; + + /// + /// Creates a success result with updated model and rotation info. + /// + public static GameActionResult SuccessResult( + object updatedModel, + bool shouldRotate = false, + bool isGameOver = false, + Guid? winnerId = null, + IReadOnlyList? triggers = null) => new() + { + Success = true, + UpdatedModel = updatedModel, + ShouldRotatePlayer = shouldRotate, + IsGameOver = isGameOver, + WinnerId = winnerId, + Triggers = triggers ?? [] + }; +} diff --git a/src/Koogle.Application/Games/IGameLogicService.cs b/src/Koogle.Application/Games/IGameLogicService.cs index 04814f7..fbf6329 100644 --- a/src/Koogle.Application/Games/IGameLogicService.cs +++ b/src/Koogle.Application/Games/IGameLogicService.cs @@ -42,6 +42,28 @@ public interface IGameLogicService /// Setup options to validate. /// Validation result with errors if any. GameSetupValidationResult ValidateSetup(object? setupOptions); + + /// + /// Gets available game-specific actions for the current state. + /// + /// Current game model. + /// ID of the current player. + /// List of available actions. Empty list if no special actions. + IReadOnlyList GetAvailableActions(object gameModel, Guid currentPlayerId); + + /// + /// Executes a game-specific action. + /// + /// Current game model. + /// Action ID to execute. + /// ID of the current player. + /// Optional action parameters. + /// Result of the action execution. + GameActionResult ExecuteAction( + object gameModel, + string actionId, + Guid currentPlayerId, + IReadOnlyDictionary? parameters = null); } /// diff --git a/src/Koogle.Application/Games/Shit/ShitGameLogicService.cs b/src/Koogle.Application/Games/Shit/ShitGameLogicService.cs index 1f2a68b..b726816 100644 --- a/src/Koogle.Application/Games/Shit/ShitGameLogicService.cs +++ b/src/Koogle.Application/Games/Shit/ShitGameLogicService.cs @@ -198,6 +198,60 @@ public class ShitGameLogicService : IGameLogicService }); } + /// + public IReadOnlyList GetAvailableActions(object gameModel, Guid currentPlayerId) + { + var model = CastModel(gameModel); + + if (model.IsGameOver) + return []; + + // Pass action only available when player has collected points + if (model.CollectedPoints > 0) + { + return + [ + new GameActionDescriptor + { + ActionId = "pass", + Label = "Passen", + Icon = "SkipNext", + Color = "Warning", + Tooltip = $"Gesammelte Punkte ({model.CollectedPoints}) an nächsten Spieler weitergeben", + Order = 10 + } + ]; + } + + return []; + } + + /// + public GameActionResult ExecuteAction( + object gameModel, + string actionId, + Guid currentPlayerId, + IReadOnlyDictionary? parameters = null) + { + return actionId switch + { + "pass" => ExecutePassAction(gameModel, currentPlayerId), + _ => GameActionResult.Failure($"Unknown action: {actionId}") + }; + } + + private GameActionResult ExecutePassAction(object gameModel, Guid currentPlayerId) + { + var (updatedModel, throwResult) = ProcessPass(gameModel, currentPlayerId); + + return GameActionResult.SuccessResult( + updatedModel, + shouldRotate: throwResult.ShouldRotatePlayer, + isGameOver: throwResult.IsGameOver, + winnerId: throwResult.WinnerId, + triggers: throwResult.Triggers); + } + /// public GameSetupValidationResult ValidateSetup(object? setupOptions) { diff --git a/src/Koogle.Application/Games/Training/TrainingGameLogicService.cs b/src/Koogle.Application/Games/Training/TrainingGameLogicService.cs index 4fc914d..3fc6c3f 100644 --- a/src/Koogle.Application/Games/Training/TrainingGameLogicService.cs +++ b/src/Koogle.Application/Games/Training/TrainingGameLogicService.cs @@ -106,6 +106,23 @@ public class TrainingGameLogicService : IGameLogicService }); } + /// + public IReadOnlyList GetAvailableActions(object gameModel, Guid currentPlayerId) + { + // Training has no special actions + return []; + } + + /// + public GameActionResult ExecuteAction( + object gameModel, + string actionId, + Guid currentPlayerId, + IReadOnlyDictionary? parameters = null) + { + return GameActionResult.Failure($"Action '{actionId}' is not supported in Training mode."); + } + /// public GameSetupValidationResult ValidateSetup(object? setupOptions) { diff --git a/src/Koogle.Web/Components/Game/GameActionPanel.razor b/src/Koogle.Web/Components/Game/GameActionPanel.razor new file mode 100644 index 0000000..6dfaa22 --- /dev/null +++ b/src/Koogle.Web/Components/Game/GameActionPanel.razor @@ -0,0 +1,118 @@ +@using Fluxor +@using Koogle.Application.Games +@using Koogle.Web.Store.GameState +@inherits Fluxor.Blazor.Web.Components.FluxorComponent +@implements IDisposable + +@inject IState GameState +@inject IDispatcher Dispatcher +@inject GameDefinitionRegistry GameRegistry + +@if (_availableActions.Count > 0) +{ +
+ @foreach (var action in _availableActions.OrderBy(a => a.Order)) + { + + @action.Label + + } +
+} + + + +@code { + private IReadOnlyList _availableActions = []; + + protected override void OnInitialized() + { + base.OnInitialized(); + GameState.StateChanged += OnGameStateChanged; + UpdateAvailableActions(); + } + + private void OnGameStateChanged(object? sender, EventArgs e) + { + UpdateAvailableActions(); + InvokeAsync(StateHasChanged); + } + + private void UpdateAvailableActions() + { + _availableActions = []; + var state = GameState.Value; + + if (!state.IsGameActive || state.GameModel == null || + string.IsNullOrEmpty(state.GameTypeName) || + !state.Participants.CurrentPlayerId.HasValue) + return; + + try + { + var logicService = GameRegistry.GetLogicService(state.GameTypeName); + _availableActions = logicService.GetAvailableActions( + state.GameModel, + state.Participants.CurrentPlayerId.Value); + } + catch + { + // Silently ignore - game type might not support actions + } + } + + private void ExecuteAction(string actionId) + { + Dispatcher.Dispatch(new ExecuteGameActionAction(actionId)); + } + + private static Color GetColor(string colorName) => colorName switch + { + "Primary" => Color.Primary, + "Secondary" => Color.Secondary, + "Warning" => Color.Warning, + "Error" => Color.Error, + "Success" => Color.Success, + "Info" => Color.Info, + _ => Color.Primary + }; + + private static string? GetIcon(string? iconName) => iconName switch + { + "SkipNext" => Icons.Material.Filled.SkipNext, + "Stop" => Icons.Material.Filled.Stop, + "PlayArrow" => Icons.Material.Filled.PlayArrow, + "Pause" => Icons.Material.Filled.Pause, + "Check" => Icons.Material.Filled.Check, + "Close" => Icons.Material.Filled.Close, + "Add" => Icons.Material.Filled.Add, + "Remove" => Icons.Material.Filled.Remove, + "Refresh" => Icons.Material.Filled.Refresh, + _ => iconName // Allow passing full icon path + }; + + public void Dispose() + { + GameState.StateChanged -= OnGameStateChanged; + } +} diff --git a/src/Koogle.Web/Components/Game/GameInputPanel.razor b/src/Koogle.Web/Components/Game/GameInputPanel.razor index 4b43f87..9483c08 100644 --- a/src/Koogle.Web/Components/Game/GameInputPanel.razor +++ b/src/Koogle.Web/Components/Game/GameInputPanel.razor @@ -68,6 +68,9 @@ + @* Game-specific actions *@ + + @* Error display *@ @if (!string.IsNullOrEmpty(GameState.Value.Error)) { diff --git a/src/Koogle.Web/Components/Game/Shit/ShitSetup.razor b/src/Koogle.Web/Components/Game/Shit/ShitSetup.razor index bbbb3c6..a43a6c1 100644 --- a/src/Koogle.Web/Components/Game/Shit/ShitSetup.razor +++ b/src/Koogle.Web/Components/Game/Shit/ShitSetup.razor @@ -55,8 +55,8 @@
  • Jeder Spieler startet mit der Start-Punktzahl
  • Geworfene Kegel werden von deinen Punkten abgezogen
  • -
  • Du kannst weiterwürfeln oder zum nächsten Spieler übergeben
  • -
  • Wirfst du die Scheiss-Zahl oder in die Rinne, bekommst du alle gesammelten Punkte dazu
  • +
  • Du kannst weiterspielen oder zum nächsten Spieler übergeben
  • +
  • Wirfst du die Scheiss-Zahl oder in die Gosse, bekommst du alle gesammelten Punkte dazu
  • Wer zuerst 0 erreicht, gewinnt!
  • Die Verlierer zahlen Strafe basierend auf ihren Restpunkten
diff --git a/src/Koogle.Web/Store/GameState/GameActions.cs b/src/Koogle.Web/Store/GameState/GameActions.cs index 4b1a837..5b4e2dc 100644 --- a/src/Koogle.Web/Store/GameState/GameActions.cs +++ b/src/Koogle.Web/Store/GameState/GameActions.cs @@ -265,3 +265,33 @@ public record ClearGameErrorAction; /// Action to update the game-specific model. ///
public record UpdateGameModelAction(object? GameModel); + +// Game Action Execution Actions + +/// +/// Action to execute a game-specific action (e.g., pass in Shit game). +/// +/// The action ID to execute. +/// Optional action parameters. +public record ExecuteGameActionAction( + string ActionId, + IReadOnlyDictionary? Parameters = null); + +/// +/// Action dispatched when game action succeeds. +/// +/// Updated game model after action. +/// Whether to rotate to next player. +/// Whether game has ended. +/// Winner ID if game is over. +public record ExecuteGameActionSuccessAction( + object? UpdatedGameModel, + bool ShouldRotatePlayer, + bool IsGameOver, + Guid? WinnerId); + +/// +/// Action dispatched when game action fails. +/// +/// Error message. +public record ExecuteGameActionFailureAction(string Error); diff --git a/src/Koogle.Web/Store/GameState/GameEffects.cs b/src/Koogle.Web/Store/GameState/GameEffects.cs index f1c0559..eb8effb 100644 --- a/src/Koogle.Web/Store/GameState/GameEffects.cs +++ b/src/Koogle.Web/Store/GameState/GameEffects.cs @@ -418,6 +418,65 @@ public class GameEffects return Task.CompletedTask; } + /// + /// Handles ExecuteGameActionAction - executes game-specific action via IGameLogicService. + /// + [EffectMethod] + public Task HandleExecuteGameAction(ExecuteGameActionAction action, IDispatcher dispatcher) + { + var state = _gameState.Value; + var gameTypeName = state.GameTypeName; + var currentPlayerId = state.Participants.CurrentPlayerId; + + if (string.IsNullOrEmpty(gameTypeName) || !currentPlayerId.HasValue || state.GameModel == null) + { + dispatcher.Dispatch(new ExecuteGameActionFailureAction("Cannot execute action: missing game state")); + return Task.CompletedTask; + } + + 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)); + + // Debounce save operations + _saveDebounceTimer?.Dispose(); + _saveDebounceTimer = new Timer( + async _ => await DebouncedSaveAsync(dispatcher), + null, + SaveDebounceMs, + Timeout.Infinite); + + _logger.LogDebug( + "Game action executed: {ActionId}, rotate={Rotate}, gameOver={GameOver}", + action.ActionId, result.ShouldRotatePlayer, result.IsGameOver); + } + 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}")); + } + + return Task.CompletedTask; + } + private static ThrowPanelSnapshot CreateThrowPanelSnapshot(ThrowPanelState state) { return ThrowPanelSnapshot.FromPins( diff --git a/src/Koogle.Web/Store/GameState/GameReducers.cs b/src/Koogle.Web/Store/GameState/GameReducers.cs index f09e843..dc2ee48 100644 --- a/src/Koogle.Web/Store/GameState/GameReducers.cs +++ b/src/Koogle.Web/Store/GameState/GameReducers.cs @@ -533,6 +533,76 @@ public static class GameReducers GameModel = action.GameModel }; + // Game Action Reducers + + /// + /// Handles ExecuteGameActionAction - creates undo snapshot before action is processed. + /// + [ReducerMethod] + public static GameState OnExecuteGameAction(GameState state, ExecuteGameActionAction action) + { + // Create snapshot of current state before action is processed + var clonedGameModel = CloneGameModel(state.GameModel); + + var snapshot = new GameSnapshot + { + ThrowPanelAfter = state.ThrowPanelAfter, + Participants = state.Participants, + GameModel = clonedGameModel + }; + + return state with + { + UndoStack = state.UndoStack.Add(snapshot), + RedoStack = [], // Clear redo stack - new action invalidates redo history + IsLoading = true + }; + } + + /// + /// Handles ExecuteGameActionSuccessAction - applies action result with player rotation. + /// + [ReducerMethod] + public static GameState OnExecuteGameActionSuccess(GameState state, ExecuteGameActionSuccessAction action) + { + var newState = state with + { + GameModel = action.UpdatedGameModel, + IsLoading = false, + IsSaving = true + }; + + // Apply player rotation if requested + if (action.ShouldRotatePlayer) + { + newState = newState with + { + Participants = newState.Participants.NextPlayer() + }; + } + + return newState; + } + + /// + /// Handles ExecuteGameActionFailureAction - sets error state and removes snapshot. + /// + [ReducerMethod] + public static GameState OnExecuteGameActionFailure(GameState state, ExecuteGameActionFailureAction action) + { + // Remove the snapshot we added in OnExecuteGameAction since action failed + var newUndoStack = state.UndoStack.Count > 0 + ? state.UndoStack.RemoveAt(state.UndoStack.Count - 1) + : state.UndoStack; + + return state with + { + UndoStack = newUndoStack, + IsLoading = false, + Error = action.Error + }; + } + // Helper Methods ///