diff --git a/src/Koogle.Application/Games/Training/TrainingGameDefinition.cs b/src/Koogle.Application/Games/Training/TrainingGameDefinition.cs new file mode 100644 index 0000000..e56a622 --- /dev/null +++ b/src/Koogle.Application/Games/Training/TrainingGameDefinition.cs @@ -0,0 +1,29 @@ +using Koogle.Domain.Interfaces; + +namespace Koogle.Application.Games.Training; + +/// +/// Game definition for Kegel-Training game type. +/// +public class TrainingGameDefinition : IGameDefinition +{ + /// + public string Name => "Training"; + + /// + public string DisplayName => "Kegel-Training"; + + /// + public Type SetupComponentType => Type.GetType( + "Koogle.Web.Components.Game.Training.TrainingSetup, Koogle.Web")!; + + /// + public Type BoardComponentType => Type.GetType( + "Koogle.Web.Components.Game.Training.TrainingBoard, Koogle.Web")!; + + /// + public Type GameLogicServiceType => typeof(TrainingGameLogicService); + + /// + public Type GameModelType => typeof(TrainingGameModel); +} diff --git a/src/Koogle.Application/Games/Training/TrainingGameLogicService.cs b/src/Koogle.Application/Games/Training/TrainingGameLogicService.cs new file mode 100644 index 0000000..03b9839 --- /dev/null +++ b/src/Koogle.Application/Games/Training/TrainingGameLogicService.cs @@ -0,0 +1,164 @@ +using System.Text.Json; + +namespace Koogle.Application.Games.Training; + +/// +/// Game logic service for Training game type. +/// Training is a practice mode where players track their throw statistics. +/// +public class TrainingGameLogicService : IGameLogicService +{ + /// + public object CreateInitialModel(Guid[] playerIds, object? setupOptions) + { + var model = new TrainingGameModel + { + PlayerStatistics = playerIds.ToDictionary( + id => id, + _ => new TrainingPlayerStats()) + }; + + return model; + } + + /// + public (object UpdatedModel, ThrowResult Result) ProcessThrow(object gameModel, AfterThrowState afterThrow) + { + var model = CastModel(gameModel); + var playerId = afterThrow.CurrentPlayerId; + + if (!model.PlayerStatistics.TryGetValue(playerId, out var stats)) + { + stats = new TrainingPlayerStats(); + model = model with + { + PlayerStatistics = new Dictionary(model.PlayerStatistics) + { + [playerId] = stats + } + }; + } + + // Update statistics + stats.ThrowCount++; + stats.PinCount += afterThrow.PinsKnocked; + + if (afterThrow.IsCircle) + { + stats.CircleCount++; + } + + if (afterThrow.IsStrike) + { + stats.StrikeCount++; + } + + if (afterThrow.IsGutter) + { + stats.GutterCount++; + } + + // Determine if player should rotate (round complete) + var shouldRotate = afterThrow.ThrowPanel.IsRoundComplete(); + + var result = new ThrowResult + { + PointsScored = afterThrow.PinsKnocked, + ShouldRotatePlayer = shouldRotate, + IsGameOver = false, // Training never auto-ends + WinnerId = null, + Triggers = [] + }; + + return (model, result); + } + + /// + public (bool IsGameOver, Guid? WinnerId) CheckGameEnd(object gameModel) + { + // Training mode never auto-ends - must be manually ended + return (false, null); + } + + /// + public IReadOnlyDictionary GetPlayerStats(object gameModel) + { + var model = CastModel(gameModel); + + return model.PlayerStatistics.ToDictionary( + kvp => kvp.Key, + kvp => new PlayerStatsSummary + { + ThrowCount = kvp.Value.ThrowCount, + PinCount = kvp.Value.PinCount, + CircleCount = kvp.Value.CircleCount, + StrikeCount = kvp.Value.StrikeCount, + CurrentPoints = kvp.Value.PinCount, // Total pins = "points" in training + CustomStats = new Dictionary + { + ["GutterCount"] = kvp.Value.GutterCount + } + }); + } + + /// + public GameSetupValidationResult ValidateSetup(object? setupOptions) + { + if (setupOptions is null) + { + // Default options are valid + return GameSetupValidationResult.Valid(); + } + + TrainingSetupOptions options; + if (setupOptions is TrainingSetupOptions typedOptions) + { + options = typedOptions; + } + else if (setupOptions is JsonElement jsonElement) + { + try + { + options = JsonSerializer.Deserialize( + jsonElement.GetRawText(), + GameModelFactory.JsonSerializerOptions)!; + } + catch + { + return GameSetupValidationResult.Invalid("Ungültige Setup-Optionen."); + } + } + else + { + return GameSetupValidationResult.Invalid("Ungültige Setup-Optionen."); + } + + var errors = new List(); + + if (options.ThrowsPerRound < 1 || options.ThrowsPerRound > 5) + { + errors.Add("Würfe pro Runde muss zwischen 1 und 5 liegen."); + } + + return errors.Count > 0 + ? GameSetupValidationResult.Invalid(errors.ToArray()) + : GameSetupValidationResult.Valid(); + } + + private static TrainingGameModel CastModel(object gameModel) + { + if (gameModel is TrainingGameModel model) + { + return model; + } + + if (gameModel is JsonElement jsonElement) + { + return JsonSerializer.Deserialize( + jsonElement.GetRawText(), + GameModelFactory.JsonSerializerOptions)!; + } + + throw new InvalidOperationException($"Expected TrainingGameModel but got {gameModel.GetType().Name}"); + } +} diff --git a/src/Koogle.Application/Games/Training/TrainingGameModel.cs b/src/Koogle.Application/Games/Training/TrainingGameModel.cs new file mode 100644 index 0000000..8551af8 --- /dev/null +++ b/src/Koogle.Application/Games/Training/TrainingGameModel.cs @@ -0,0 +1,71 @@ +using Koogle.Domain.Enums; + +namespace Koogle.Application.Games.Training; + +/// +/// Game model for Training game type. Stores player statistics. +/// +public record TrainingGameModel +{ + /// + /// Statistics for each player in the game. + /// + public Dictionary PlayerStatistics { get; init; } = new(); +} + +/// +/// Statistics for a single player in a training game. +/// +public record TrainingPlayerStats +{ + /// + /// Total number of throws made. + /// + public int ThrowCount { get; set; } + + /// + /// Total number of pins knocked down. + /// + public int PinCount { get; set; } + + /// + /// Number of circles (Kranz) scored - 8 outer pins down, center standing. + /// + public int CircleCount { get; set; } + + /// + /// Number of strikes scored - all 9 pins knocked down. + /// + public int StrikeCount { get; set; } + + /// + /// Number of gutters (Rinne) - no pins knocked down. + /// + public int GutterCount { get; set; } + + /// + /// Calculates the average pins per throw. + /// + public double Average => ThrowCount > 0 ? (double)PinCount / ThrowCount : 0; +} + +/// +/// Setup options for Training game. +/// +public record TrainingSetupOptions +{ + /// + /// Throw mode: Reposition (in die Vollen) or Decrease (Abräumen). + /// + public ThrowMode ThrowMode { get; init; } = ThrowMode.Reposition; + + /// + /// Number of throws per round (1-5, default 3). + /// + public int ThrowsPerRound { get; init; } = 3; + + /// + /// Mode for player rotation. + /// + public ParticipantsMode ParticipantsMode { get; init; } = ParticipantsMode.GameLogic; +} diff --git a/src/Koogle.Web/Components/Game/Training/TrainingBoard.razor b/src/Koogle.Web/Components/Game/Training/TrainingBoard.razor new file mode 100644 index 0000000..8126871 --- /dev/null +++ b/src/Koogle.Web/Components/Game/Training/TrainingBoard.razor @@ -0,0 +1,218 @@ +@using Fluxor +@using Koogle.Application.DTOs +@using Koogle.Application.Games +@using Koogle.Application.Games.Training +@using Koogle.Web.Store.GameState +@using Koogle.Web.Store.PersonState +@using MudBlazor + +@inherits Fluxor.Blazor.Web.Components.FluxorComponent + +@implements IDisposable +@inject IState GameState +@inject IState PersonState + + + + + Training - Tafel + + + @if (_playerStats.Count == 0) + { + + Noch keine Statistiken vorhanden. Starte mit dem Werfen! + + } + else + { + + + Spieler + Würfe + Kegel + Kränze + Strikes + Rinnen + + + + + @if (context.IsCurrentPlayer) + { + + + @context.PlayerName + + + } + else + { + @context.PlayerName + } + + @context.ThrowCount + @context.PinCount + + @if (context.CircleCount > 0) + { + + @context.CircleCount + + } + else + { + 0 + } + + + @if (context.StrikeCount > 0) + { + + @context.StrikeCount + + } + else + { + 0 + } + + + @if (context.GutterCount > 0) + { + + @context.GutterCount + + } + else + { + 0 + } + + + @context.Average.ToString("F1") + + + + + @* Summary row *@ + + + + Gesamt: @_totalThrows Würfe, @_totalPins Kegel + + + Team-⌀: @_teamAverage.ToString("F2") + + + + } + + +@code { + private List _playerStats = []; + private int _totalThrows; + private int _totalPins; + private double _teamAverage; + + protected override void OnInitialized() + { + base.OnInitialized(); + GameState.StateChanged += OnGameStateChanged; + UpdateStats(); + } + + private void OnGameStateChanged(object? sender, EventArgs e) + { + UpdateStats(); + InvokeAsync(StateHasChanged); + } + + private void UpdateStats() + { + _playerStats.Clear(); + _totalThrows = 0; + _totalPins = 0; + + var gameState = GameState.Value; + if (gameState.GameModel is not TrainingGameModel model) + { + // Try to deserialize if it's a JsonElement + if (gameState.GameModel is System.Text.Json.JsonElement jsonElement) + { + try + { + model = System.Text.Json.JsonSerializer.Deserialize( + jsonElement.GetRawText(), + GameModelFactory.JsonSerializerOptions); + } + catch + { + return; + } + } + else + { + return; + } + } + + if (model?.PlayerStatistics == null) + { + return; + } + + var currentPlayerId = gameState.Participants.CurrentPlayerId; + var persons = PersonState.Value.Persons; + + foreach (var (playerId, stats) in model.PlayerStatistics) + { + var person = persons.FirstOrDefault(p => p.Id == playerId); + var playerName = person?.Name ?? "Unbekannt"; + + _playerStats.Add(new PlayerStatsRow + { + PlayerId = playerId, + PlayerName = playerName, + ThrowCount = stats.ThrowCount, + PinCount = stats.PinCount, + CircleCount = stats.CircleCount, + StrikeCount = stats.StrikeCount, + GutterCount = stats.GutterCount, + Average = stats.Average, + IsCurrentPlayer = playerId == currentPlayerId + }); + + _totalThrows += stats.ThrowCount; + _totalPins += stats.PinCount; + } + + // Sort by pin count descending (best player first) + _playerStats = _playerStats.OrderByDescending(p => p.PinCount).ToList(); + + _teamAverage = _totalThrows > 0 ? (double)_totalPins / _totalThrows : 0; + } + + public void Dispose() + { + GameState.StateChanged -= OnGameStateChanged; + } + + private record PlayerStatsRow + { + public Guid PlayerId { get; init; } + public string PlayerName { get; init; } = ""; + public int ThrowCount { get; init; } + public int PinCount { get; init; } + public int CircleCount { get; init; } + public int StrikeCount { get; init; } + public int GutterCount { get; init; } + public double Average { get; init; } + public bool IsCurrentPlayer { get; init; } + } +} diff --git a/src/Koogle.Web/Components/Game/Training/TrainingSetup.razor b/src/Koogle.Web/Components/Game/Training/TrainingSetup.razor new file mode 100644 index 0000000..78e1c16 --- /dev/null +++ b/src/Koogle.Web/Components/Game/Training/TrainingSetup.razor @@ -0,0 +1,126 @@ +@using Koogle.Application.Games.Training +@using Koogle.Domain.Enums +@using MudBlazor + + + Training-Einstellungen + + + + In die Vollen + Abräumen + + + + + + Automatisch + Frei wählbar + + + + + + + Training ist ein freies Übungsspiel. Es gibt keinen Gewinner - nur Statistiken. + Das Spiel muss manuell beendet werden. + + + +@code { + /// + /// Callback when setup options change. + /// + [Parameter] + public EventCallback OnOptionsChanged { get; set; } + + /// + /// Initial setup options. + /// + [Parameter] + public object? InitialOptions { get; set; } + + private TrainingSetupOptionsInternal _options = new(); + + protected override void OnInitialized() + { + if (InitialOptions is TrainingSetupOptions options) + { + _options = new TrainingSetupOptionsInternal + { + ThrowMode = options.ThrowMode, + ThrowsPerRound = options.ThrowsPerRound, + ParticipantsMode = options.ParticipantsMode + }; + } + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + await NotifyOptionsChanged(); + } + } + + private async Task NotifyOptionsChanged() + { + var options = new TrainingSetupOptions + { + ThrowMode = _options.ThrowMode, + ThrowsPerRound = _options.ThrowsPerRound, + ParticipantsMode = _options.ParticipantsMode + }; + await OnOptionsChanged.InvokeAsync(options); + } + + /// + /// Gets the current setup options. + /// + public TrainingSetupOptions GetOptions() => new() + { + ThrowMode = _options.ThrowMode, + ThrowsPerRound = _options.ThrowsPerRound, + ParticipantsMode = _options.ParticipantsMode + }; + + // Internal mutable class for two-way binding + private class TrainingSetupOptionsInternal + { + private ThrowMode _throwMode = ThrowMode.Reposition; + private int _throwsPerRound = 3; + private ParticipantsMode _participantsMode = ParticipantsMode.GameLogic; + + public ThrowMode ThrowMode + { + get => _throwMode; + set => _throwMode = value; + } + + public int ThrowsPerRound + { + get => _throwsPerRound; + set => _throwsPerRound = value; + } + + public ParticipantsMode ParticipantsMode + { + get => _participantsMode; + set => _participantsMode = value; + } + } +} diff --git a/src/Koogle.Web/Program.cs b/src/Koogle.Web/Program.cs index 2d41035..af59862 100644 --- a/src/Koogle.Web/Program.cs +++ b/src/Koogle.Web/Program.cs @@ -2,6 +2,8 @@ using System.Reflection; using Fluxor; using Fluxor.Blazor.Web.ReduxDevTools; using Koogle.Application; +using Koogle.Application.Games; +using Koogle.Application.Games.Training; using Koogle.Infrastructure; using Koogle.Infrastructure.Security; using Koogle.Web.Components; @@ -19,6 +21,9 @@ builder.Services.AddInfrastructure(builder.Configuration); // Application Layer (Services, AutoMapper, Validators) builder.Services.AddApplication(); +// Game Types +builder.Services.AddGameType(); + // FluentValidation //builder.Services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly()); @@ -80,6 +85,14 @@ app.UseAuthorization(); using (var scope = app.Services.CreateScope()) { await IdentityRoleSeeder.SeedAsync(scope.ServiceProvider); + + // Initialize game types + var registry = scope.ServiceProvider.GetRequiredService(); + var initializers = scope.ServiceProvider.GetServices(); + foreach (var initializer in initializers) + { + initializer.Initialize(registry); + } } await BootstrapSeeder.SeedAsync(app.Services, app.Configuration, app.Environment);