added GameAction Framework

This commit is contained in:
beo3000 2025-12-28 08:34:23 +01:00
parent 82c6c2d91d
commit 22fbb79801
10 changed files with 481 additions and 2 deletions

View File

@ -0,0 +1,106 @@
namespace Koogle.Application.Games;
/// <summary>
/// Describes a game-specific action available in the UI.
/// </summary>
public record GameActionDescriptor
{
/// <summary>
/// Unique identifier for this action (e.g., "pass", "double_down").
/// </summary>
public required string ActionId { get; init; }
/// <summary>
/// Display label for the button/menu item.
/// </summary>
public required string Label { get; init; }
/// <summary>
/// Optional icon (MudBlazor icon name, e.g., "Icons.Material.Filled.SkipNext").
/// </summary>
public string? Icon { get; init; }
/// <summary>
/// Color hint for the button (maps to MudBlazor Color: Primary, Secondary, Warning, Error, Success, Info).
/// </summary>
public string Color { get; init; } = "Primary";
/// <summary>
/// Whether action is enabled based on current game state.
/// </summary>
public bool IsEnabled { get; init; } = true;
/// <summary>
/// Optional tooltip or helper text.
/// </summary>
public string? Tooltip { get; init; }
/// <summary>
/// Display order for sorting (lower = first).
/// </summary>
public int Order { get; init; } = 100;
}
/// <summary>
/// Result of executing a game action.
/// </summary>
public record GameActionResult
{
/// <summary>
/// Whether action was successful.
/// </summary>
public bool Success { get; init; }
/// <summary>
/// Updated game model after action.
/// </summary>
public object? UpdatedModel { get; init; }
/// <summary>
/// Whether to rotate to next player.
/// </summary>
public bool ShouldRotatePlayer { get; init; }
/// <summary>
/// Whether game has ended.
/// </summary>
public bool IsGameOver { get; init; }
/// <summary>
/// Winner ID if game is over.
/// </summary>
public Guid? WinnerId { get; init; }
/// <summary>
/// Error message if action failed.
/// </summary>
public string? Error { get; init; }
/// <summary>
/// Trigger events (e.g., penalties).
/// </summary>
public IReadOnlyList<TriggerEvent> Triggers { get; init; } = [];
/// <summary>
/// Creates a failure result with error message.
/// </summary>
public static GameActionResult Failure(string error) => new() { Success = false, Error = error };
/// <summary>
/// Creates a success result with updated model and rotation info.
/// </summary>
public static GameActionResult SuccessResult(
object updatedModel,
bool shouldRotate = false,
bool isGameOver = false,
Guid? winnerId = null,
IReadOnlyList<TriggerEvent>? triggers = null) => new()
{
Success = true,
UpdatedModel = updatedModel,
ShouldRotatePlayer = shouldRotate,
IsGameOver = isGameOver,
WinnerId = winnerId,
Triggers = triggers ?? []
};
}

View File

@ -42,6 +42,28 @@ public interface IGameLogicService
/// <param name="setupOptions">Setup options to validate.</param>
/// <returns>Validation result with errors if any.</returns>
GameSetupValidationResult ValidateSetup(object? setupOptions);
/// <summary>
/// Gets available game-specific actions for the current state.
/// </summary>
/// <param name="gameModel">Current game model.</param>
/// <param name="currentPlayerId">ID of the current player.</param>
/// <returns>List of available actions. Empty list if no special actions.</returns>
IReadOnlyList<GameActionDescriptor> GetAvailableActions(object gameModel, Guid currentPlayerId);
/// <summary>
/// Executes a game-specific action.
/// </summary>
/// <param name="gameModel">Current game model.</param>
/// <param name="actionId">Action ID to execute.</param>
/// <param name="currentPlayerId">ID of the current player.</param>
/// <param name="parameters">Optional action parameters.</param>
/// <returns>Result of the action execution.</returns>
GameActionResult ExecuteAction(
object gameModel,
string actionId,
Guid currentPlayerId,
IReadOnlyDictionary<string, object>? parameters = null);
}
/// <summary>

View File

@ -198,6 +198,60 @@ public class ShitGameLogicService : IGameLogicService
});
}
/// <inheritdoc />
public IReadOnlyList<GameActionDescriptor> 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 [];
}
/// <inheritdoc />
public GameActionResult ExecuteAction(
object gameModel,
string actionId,
Guid currentPlayerId,
IReadOnlyDictionary<string, object>? 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);
}
/// <inheritdoc />
public GameSetupValidationResult ValidateSetup(object? setupOptions)
{

View File

@ -106,6 +106,23 @@ public class TrainingGameLogicService : IGameLogicService
});
}
/// <inheritdoc />
public IReadOnlyList<GameActionDescriptor> GetAvailableActions(object gameModel, Guid currentPlayerId)
{
// Training has no special actions
return [];
}
/// <inheritdoc />
public GameActionResult ExecuteAction(
object gameModel,
string actionId,
Guid currentPlayerId,
IReadOnlyDictionary<string, object>? parameters = null)
{
return GameActionResult.Failure($"Action '{actionId}' is not supported in Training mode.");
}
/// <inheritdoc />
public GameSetupValidationResult ValidateSetup(object? setupOptions)
{

View File

@ -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> GameState
@inject IDispatcher Dispatcher
@inject GameDefinitionRegistry GameRegistry
@if (_availableActions.Count > 0)
{
<div class="game-action-panel">
@foreach (var action in _availableActions.OrderBy(a => a.Order))
{
<MudButton Variant="Variant.Filled"
Color="@GetColor(action.Color)"
StartIcon="@GetIcon(action.Icon)"
Disabled="@(!action.IsEnabled || GameState.Value.IsSaving || GameState.Value.IsLoading)"
OnClick="@(() => ExecuteAction(action.ActionId))"
Title="@action.Tooltip"
Class="game-action-button">
@action.Label
</MudButton>
}
</div>
}
<style>
.game-action-panel {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: center;
padding: 8px;
background: var(--mud-palette-surface);
border-radius: 8px;
}
.game-action-button {
min-width: 120px;
}
</style>
@code {
private IReadOnlyList<GameActionDescriptor> _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;
}
}

View File

@ -68,6 +68,9 @@
<UndoRedoButtons IsDisabled="@GameState.Value.IsSaving" />
</div>
@* Game-specific actions *@
<GameActionPanel />
@* Error display *@
@if (!string.IsNullOrEmpty(GameState.Value.Error))
{

View File

@ -55,8 +55,8 @@
<ul style="margin: 8px 0 0 16px; padding: 0;">
<li>Jeder Spieler startet mit der Start-Punktzahl</li>
<li>Geworfene Kegel werden von deinen Punkten abgezogen</li>
<li>Du kannst weiterwürfeln oder zum nächsten Spieler übergeben</li>
<li>Wirfst du die Scheiss-Zahl oder in die Rinne, bekommst du alle gesammelten Punkte dazu</li>
<li>Du kannst weiterspielen oder zum nächsten Spieler übergeben</li>
<li>Wirfst du die Scheiss-Zahl oder in die Gosse, bekommst du alle gesammelten Punkte dazu</li>
<li>Wer zuerst 0 erreicht, gewinnt!</li>
<li>Die Verlierer zahlen Strafe basierend auf ihren Restpunkten</li>
</ul>

View File

@ -265,3 +265,33 @@ public record ClearGameErrorAction;
/// Action to update the game-specific model.
/// </summary>
public record UpdateGameModelAction(object? GameModel);
// Game Action Execution Actions
/// <summary>
/// Action to execute a game-specific action (e.g., pass in Shit game).
/// </summary>
/// <param name="ActionId">The action ID to execute.</param>
/// <param name="Parameters">Optional action parameters.</param>
public record ExecuteGameActionAction(
string ActionId,
IReadOnlyDictionary<string, object>? Parameters = null);
/// <summary>
/// Action dispatched when game action succeeds.
/// </summary>
/// <param name="UpdatedGameModel">Updated game model after action.</param>
/// <param name="ShouldRotatePlayer">Whether to rotate to next player.</param>
/// <param name="IsGameOver">Whether game has ended.</param>
/// <param name="WinnerId">Winner ID if game is over.</param>
public record ExecuteGameActionSuccessAction(
object? UpdatedGameModel,
bool ShouldRotatePlayer,
bool IsGameOver,
Guid? WinnerId);
/// <summary>
/// Action dispatched when game action fails.
/// </summary>
/// <param name="Error">Error message.</param>
public record ExecuteGameActionFailureAction(string Error);

View File

@ -418,6 +418,65 @@ public class GameEffects
return Task.CompletedTask;
}
/// <summary>
/// Handles ExecuteGameActionAction - executes game-specific action via IGameLogicService.
/// </summary>
[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(

View File

@ -533,6 +533,76 @@ public static class GameReducers
GameModel = action.GameModel
};
// Game Action Reducers
/// <summary>
/// Handles ExecuteGameActionAction - creates undo snapshot before action is processed.
/// </summary>
[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
};
}
/// <summary>
/// Handles ExecuteGameActionSuccessAction - applies action result with player rotation.
/// </summary>
[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;
}
/// <summary>
/// Handles ExecuteGameActionFailureAction - sets error state and removes snapshot.
/// </summary>
[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
/// <summary>