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