Add Training Game (Phase H4)

- TrainingGameDefinition with game type metadata
- TrainingGameModel + TrainingPlayerStats for stats tracking
- TrainingGameLogicService implementing game logic
- TrainingSetup.razor for config (ThrowMode, ThrowsPerRound, ParticipantsMode)
- TrainingBoard.razor showing player stats table with totals/averages
- Game type registration in Program.cs

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
beo3000 2025-12-26 14:49:26 +01:00
parent 8f4cc740a1
commit 8824162cd9
6 changed files with 621 additions and 0 deletions

View File

@ -0,0 +1,29 @@
using Koogle.Domain.Interfaces;
namespace Koogle.Application.Games.Training;
/// <summary>
/// Game definition for Kegel-Training game type.
/// </summary>
public class TrainingGameDefinition : IGameDefinition
{
/// <inheritdoc />
public string Name => "Training";
/// <inheritdoc />
public string DisplayName => "Kegel-Training";
/// <inheritdoc />
public Type SetupComponentType => Type.GetType(
"Koogle.Web.Components.Game.Training.TrainingSetup, Koogle.Web")!;
/// <inheritdoc />
public Type BoardComponentType => Type.GetType(
"Koogle.Web.Components.Game.Training.TrainingBoard, Koogle.Web")!;
/// <inheritdoc />
public Type GameLogicServiceType => typeof(TrainingGameLogicService);
/// <inheritdoc />
public Type GameModelType => typeof(TrainingGameModel);
}

View File

@ -0,0 +1,164 @@
using System.Text.Json;
namespace Koogle.Application.Games.Training;
/// <summary>
/// Game logic service for Training game type.
/// Training is a practice mode where players track their throw statistics.
/// </summary>
public class TrainingGameLogicService : IGameLogicService
{
/// <inheritdoc />
public object CreateInitialModel(Guid[] playerIds, object? setupOptions)
{
var model = new TrainingGameModel
{
PlayerStatistics = playerIds.ToDictionary(
id => id,
_ => new TrainingPlayerStats())
};
return model;
}
/// <inheritdoc />
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<Guid, TrainingPlayerStats>(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);
}
/// <inheritdoc />
public (bool IsGameOver, Guid? WinnerId) CheckGameEnd(object gameModel)
{
// Training mode never auto-ends - must be manually ended
return (false, null);
}
/// <inheritdoc />
public IReadOnlyDictionary<Guid, PlayerStatsSummary> 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<string, object>
{
["GutterCount"] = kvp.Value.GutterCount
}
});
}
/// <inheritdoc />
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<TrainingSetupOptions>(
jsonElement.GetRawText(),
GameModelFactory.JsonSerializerOptions)!;
}
catch
{
return GameSetupValidationResult.Invalid("Ungültige Setup-Optionen.");
}
}
else
{
return GameSetupValidationResult.Invalid("Ungültige Setup-Optionen.");
}
var errors = new List<string>();
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<TrainingGameModel>(
jsonElement.GetRawText(),
GameModelFactory.JsonSerializerOptions)!;
}
throw new InvalidOperationException($"Expected TrainingGameModel but got {gameModel.GetType().Name}");
}
}

View File

@ -0,0 +1,71 @@
using Koogle.Domain.Enums;
namespace Koogle.Application.Games.Training;
/// <summary>
/// Game model for Training game type. Stores player statistics.
/// </summary>
public record TrainingGameModel
{
/// <summary>
/// Statistics for each player in the game.
/// </summary>
public Dictionary<Guid, TrainingPlayerStats> PlayerStatistics { get; init; } = new();
}
/// <summary>
/// Statistics for a single player in a training game.
/// </summary>
public record TrainingPlayerStats
{
/// <summary>
/// Total number of throws made.
/// </summary>
public int ThrowCount { get; set; }
/// <summary>
/// Total number of pins knocked down.
/// </summary>
public int PinCount { get; set; }
/// <summary>
/// Number of circles (Kranz) scored - 8 outer pins down, center standing.
/// </summary>
public int CircleCount { get; set; }
/// <summary>
/// Number of strikes scored - all 9 pins knocked down.
/// </summary>
public int StrikeCount { get; set; }
/// <summary>
/// Number of gutters (Rinne) - no pins knocked down.
/// </summary>
public int GutterCount { get; set; }
/// <summary>
/// Calculates the average pins per throw.
/// </summary>
public double Average => ThrowCount > 0 ? (double)PinCount / ThrowCount : 0;
}
/// <summary>
/// Setup options for Training game.
/// </summary>
public record TrainingSetupOptions
{
/// <summary>
/// Throw mode: Reposition (in die Vollen) or Decrease (Abräumen).
/// </summary>
public ThrowMode ThrowMode { get; init; } = ThrowMode.Reposition;
/// <summary>
/// Number of throws per round (1-5, default 3).
/// </summary>
public int ThrowsPerRound { get; init; } = 3;
/// <summary>
/// Mode for player rotation.
/// </summary>
public ParticipantsMode ParticipantsMode { get; init; } = ParticipantsMode.GameLogic;
}

View File

@ -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> GameState
@inject IState<PersonState> PersonState
<MudPaper Class="pa-4">
<MudText Typo="Typo.h6" Class="mb-4">
<MudIcon Icon="@Icons.Material.Filled.TableChart" Class="mr-2" />
Training - Tafel
</MudText>
@if (_playerStats.Count == 0)
{
<MudAlert Severity="Severity.Info">
Noch keine Statistiken vorhanden. Starte mit dem Werfen!
</MudAlert>
}
else
{
<MudTable Items="@_playerStats"
Dense="true"
Hover="true"
Striped="true"
Bordered="true"
Class="mb-4">
<HeaderContent>
<MudTh>Spieler</MudTh>
<MudTh Style="text-align: right">Würfe</MudTh>
<MudTh Style="text-align: right">Kegel</MudTh>
<MudTh Style="text-align: right">Kränze</MudTh>
<MudTh Style="text-align: right">Strikes</MudTh>
<MudTh Style="text-align: right">Rinnen</MudTh>
<MudTh Style="text-align: right">⌀</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>
@if (context.IsCurrentPlayer)
{
<MudBadge Color="Color.Primary" Dot="true" Overlap="true">
<MudText Typo="Typo.body1" Style="font-weight: 600">
@context.PlayerName
</MudText>
</MudBadge>
}
else
{
<MudText Typo="Typo.body1">@context.PlayerName</MudText>
}
</MudTd>
<MudTd Style="text-align: right">@context.ThrowCount</MudTd>
<MudTd Style="text-align: right">@context.PinCount</MudTd>
<MudTd Style="text-align: right">
@if (context.CircleCount > 0)
{
<MudChip T="string" Size="Size.Small" Color="Color.Success" Variant="Variant.Filled">
@context.CircleCount
</MudChip>
}
else
{
<span>0</span>
}
</MudTd>
<MudTd Style="text-align: right">
@if (context.StrikeCount > 0)
{
<MudChip T="string" Size="Size.Small" Color="Color.Warning" Variant="Variant.Filled">
@context.StrikeCount
</MudChip>
}
else
{
<span>0</span>
}
</MudTd>
<MudTd Style="text-align: right">
@if (context.GutterCount > 0)
{
<MudChip T="string" Size="Size.Small" Color="Color.Error" Variant="Variant.Filled">
@context.GutterCount
</MudChip>
}
else
{
<span>0</span>
}
</MudTd>
<MudTd Style="text-align: right; font-weight: 600">
@context.Average.ToString("F1")
</MudTd>
</RowTemplate>
</MudTable>
@* Summary row *@
<MudPaper Class="pa-3 mt-4" Elevation="0" Style="background-color: var(--mud-palette-background-grey);">
<MudStack Row="true" Justify="Justify.SpaceBetween">
<MudText Typo="Typo.body2">
<strong>Gesamt:</strong> @_totalThrows Würfe, @_totalPins Kegel
</MudText>
<MudText Typo="Typo.body2">
<strong>Team-⌀:</strong> @_teamAverage.ToString("F2")
</MudText>
</MudStack>
</MudPaper>
}
</MudPaper>
@code {
private List<PlayerStatsRow> _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<TrainingGameModel>(
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; }
}
}

View File

@ -0,0 +1,126 @@
@using Koogle.Application.Games.Training
@using Koogle.Domain.Enums
@using MudBlazor
<MudPaper Class="pa-4">
<MudText Typo="Typo.h6" Class="mb-4">Training-Einstellungen</MudText>
<MudStack Spacing="4">
<MudSelect T="ThrowMode"
@bind-Value="_options.ThrowMode"
Label="Wurf-Modus"
Variant="Variant.Outlined"
HelperText="In die Vollen: Kegel werden nach jedem Wurf aufgestellt. Abräumen: Kegel bleiben liegen.">
<MudSelectItem Value="ThrowMode.Reposition">In die Vollen</MudSelectItem>
<MudSelectItem Value="ThrowMode.Decrease">Abräumen</MudSelectItem>
</MudSelect>
<MudNumericField T="int"
@bind-Value="_options.ThrowsPerRound"
Label="Würfe pro Runde"
Variant="Variant.Outlined"
Min="1"
Max="5"
HelperText="Anzahl Würfe bevor der nächste Spieler an der Reihe ist (1-5)" />
<MudSelect T="ParticipantsMode"
@bind-Value="_options.ParticipantsMode"
Label="Spieler-Rotation"
Variant="Variant.Outlined"
HelperText="Automatisch: Spieler wechseln nach der Runde. Frei wählbar: Jeder kann werfen.">
<MudSelectItem Value="ParticipantsMode.GameLogic">Automatisch</MudSelectItem>
<MudSelectItem Value="ParticipantsMode.FreeToChoose">Frei wählbar</MudSelectItem>
</MudSelect>
</MudStack>
<MudDivider Class="my-4" />
<MudText Typo="Typo.body2" Color="Color.Secondary">
Training ist ein freies Übungsspiel. Es gibt keinen Gewinner - nur Statistiken.
Das Spiel muss manuell beendet werden.
</MudText>
</MudPaper>
@code {
/// <summary>
/// Callback when setup options change.
/// </summary>
[Parameter]
public EventCallback<object> OnOptionsChanged { get; set; }
/// <summary>
/// Initial setup options.
/// </summary>
[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);
}
/// <summary>
/// Gets the current setup options.
/// </summary>
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;
}
}
}

View File

@ -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<TrainingGameDefinition, TrainingGameLogicService>();
// 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<GameDefinitionRegistry>();
var initializers = scope.ServiceProvider.GetServices<IGameTypeInitializer>();
foreach (var initializer in initializers)
{
initializer.Initialize(registry);
}
}
await BootstrapSeeder.SeedAsync(app.Services, app.Configuration, app.Environment);