Add Game Definition Framework (Phase H3)

- IGameDefinition interface in Domain
- GameProgress.cs with throw state records
- IGameLogicService interface
- GameDefinitionRegistry for polymorphic game types
- GameModelFactory for JSON serialization
- DI registration extensions

🤖 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:38:21 +01:00
parent d59005f6df
commit 0207c5fe80
5 changed files with 571 additions and 1 deletions

View File

@ -1,4 +1,5 @@
using Koogle.Application.Interfaces;
using Koogle.Application.Games;
using Koogle.Application.Interfaces;
using Koogle.Application.Services;
using Koogle.Domain.Interfaces;
using Microsoft.AspNetCore.Authorization;
@ -16,6 +17,8 @@ namespace Koogle.Application
{
public static IServiceCollection AddApplication(this IServiceCollection services)
{
// Game Framework
services.AddGameFramework();
// AutoMapper
services.AddAutoMapper(cfg => cfg.LicenseKey = "eyJhbGciOiJSUzI1NiIsImtpZCI6Ikx1Y2t5UGVubnlTb2Z0d2FyZUxpY2Vuc2VLZXkvYmJiMTNhY2I1OTkwNGQ4OWI0Y2IxYzg1ZjA4OGNjZjkiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL2x1Y2t5cGVubnlzb2Z0d2FyZS5jb20iLCJhdWQiOiJMdWNreVBlbm55U29mdHdhcmUiLCJleHAiOiIxNzk3MjkyODAwIiwiaWF0IjoiMTc2NTgyNzkzMyIsImFjY291bnRfaWQiOiIwMTliMjM4YjYxZGM3MWYwOTAyYmY5OGU0NzNmOTY5ZSIsImN1c3RvbWVyX2lkIjoiY3RtXzAxa2NocnF3dHNkczM4cjJnOTBncmZ5ajA1Iiwic3ViX2lkIjoiLSIsImVkaXRpb24iOiIwIiwidHlwZSI6IjIifQ.ehnUm61bFyXv2s0RScHd3vV2wIRivFN-phslB65UxztWBsk1EAtqTgPT55ONQ6-k7zi7G1vpLUUz9NL4EfpMRwgl1obeCTrs1pzvIRkednzrSdcPKAOmil-xiCS1TlrvaLzLnBWCr0JroGrAmtMYjsfUpYayRx9x8BzjUGBPUvb6eUR6wSbEtPzZbycgsp4Oj4Wwi23o56UGWHdNz7R8ofKy9EyzrgiG1uYfJZUDB_B5uWtdWi5M2bfoYHcLj-7VbSJlVlW2RoETEYuylBjG0Bwg_ZtSoVsCuqi_qV_GuyBOKbPpjFK4NFcInYFkAxAyzV_uTaFQpCFukPpCMZpFtA",
Assembly.GetExecutingAssembly());
@ -33,6 +36,9 @@ namespace Koogle.Application
services.AddScoped<IEmailService, StubEmailService>();
services.AddScoped<ITriggerService, TriggerService>();
// Note: Game types are registered in Koogle.Web where Blazor components are defined
// Use services.AddGameType<TDefinition, TLogicService>() to register game types
return services;
}
}

View File

@ -0,0 +1,177 @@
using Koogle.Domain.Interfaces;
using Microsoft.Extensions.DependencyInjection;
using System.Text.Json;
namespace Koogle.Application.Games;
/// <summary>
/// Registry for game definitions. Manages available game types.
/// </summary>
public class GameDefinitionRegistry
{
private readonly Dictionary<string, IGameDefinition> _definitions = new(StringComparer.OrdinalIgnoreCase);
private readonly IServiceProvider _serviceProvider;
/// <summary>
/// Creates new registry with service provider for resolving game logic services.
/// </summary>
public GameDefinitionRegistry(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
}
/// <summary>
/// Registers a game definition.
/// </summary>
public void Register(IGameDefinition definition)
{
_definitions[definition.Name] = definition;
}
/// <summary>
/// Gets all registered game definitions.
/// </summary>
public IReadOnlyList<IGameDefinition> GetAll() => _definitions.Values.ToList();
/// <summary>
/// Gets a game definition by name.
/// </summary>
/// <param name="name">Game type name (e.g., "Training").</param>
/// <returns>Game definition or null if not found.</returns>
public IGameDefinition? Get(string name)
=> _definitions.TryGetValue(name, out var def) ? def : null;
/// <summary>
/// Gets a game definition by name, throwing if not found.
/// </summary>
public IGameDefinition GetRequired(string name)
=> Get(name) ?? throw new InvalidOperationException($"Game type '{name}' is not registered.");
/// <summary>
/// Resolves the game logic service for a game type.
/// </summary>
/// <param name="name">Game type name.</param>
/// <returns>Game logic service instance.</returns>
public IGameLogicService GetLogicService(string name)
{
var definition = GetRequired(name);
return (IGameLogicService)_serviceProvider.GetRequiredService(definition.GameLogicServiceType);
}
/// <summary>
/// Checks if a game type is registered.
/// </summary>
public bool IsRegistered(string name) => _definitions.ContainsKey(name);
}
/// <summary>
/// Factory for deserializing polymorphic game models.
/// </summary>
public static class GameModelFactory
{
private static readonly Dictionary<string, Type> _modelTypes = new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// Registers a game model type for deserialization.
/// </summary>
public static void RegisterModelType(string gameType, Type modelType)
{
_modelTypes[gameType] = modelType;
}
/// <summary>
/// Deserializes a game model from JSON based on game type.
/// </summary>
/// <param name="json">JSON string containing the game model.</param>
/// <param name="gameType">Game type name for polymorphic deserialization.</param>
/// <returns>Deserialized game model object.</returns>
public static object Deserialize(string json, string gameType)
{
if (!_modelTypes.TryGetValue(gameType, out var modelType))
{
throw new NotSupportedException($"Unknown game type: {gameType}. Ensure the game model is registered.");
}
return JsonSerializer.Deserialize(json, modelType, JsonSerializerOptions)
?? throw new InvalidOperationException($"Failed to deserialize game model for type: {gameType}");
}
/// <summary>
/// Serializes a game model to JSON.
/// </summary>
/// <param name="model">Game model object.</param>
/// <returns>JSON string.</returns>
public static string Serialize(object model)
{
return JsonSerializer.Serialize(model, model.GetType(), JsonSerializerOptions);
}
/// <summary>
/// JSON serializer options for game models.
/// </summary>
public static JsonSerializerOptions JsonSerializerOptions { get; } = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false
};
}
/// <summary>
/// Extension methods for registering game definitions in DI.
/// </summary>
public static class GameRegistrationExtensions
{
/// <summary>
/// Adds game framework services to DI.
/// </summary>
public static IServiceCollection AddGameFramework(this IServiceCollection services)
{
services.AddSingleton<GameDefinitionRegistry>();
return services;
}
/// <summary>
/// Registers a game definition and its logic service.
/// </summary>
/// <typeparam name="TDefinition">Game definition type.</typeparam>
/// <typeparam name="TLogicService">Game logic service type.</typeparam>
public static IServiceCollection AddGameType<TDefinition, TLogicService>(this IServiceCollection services)
where TDefinition : class, IGameDefinition, new()
where TLogicService : class, IGameLogicService
{
// Register the logic service
services.AddScoped<TLogicService>();
services.AddScoped(typeof(TLogicService), typeof(TLogicService));
// Register initializer to add definition to registry
services.AddTransient<IGameTypeInitializer>(sp =>
new GameTypeInitializer<TDefinition>());
return services;
}
}
/// <summary>
/// Interface for game type initialization.
/// </summary>
public interface IGameTypeInitializer
{
/// <summary>
/// Initializes the game type by registering it with the registry.
/// </summary>
void Initialize(GameDefinitionRegistry registry);
}
/// <summary>
/// Initializer for a specific game type.
/// </summary>
internal class GameTypeInitializer<TDefinition> : IGameTypeInitializer
where TDefinition : class, IGameDefinition, new()
{
public void Initialize(GameDefinitionRegistry registry)
{
var definition = new TDefinition();
registry.Register(definition);
GameModelFactory.RegisterModelType(definition.Name, definition.GameModelType);
}
}

View File

@ -0,0 +1,220 @@
using Koogle.Domain.Enums;
namespace Koogle.Application.Games;
/// <summary>
/// Represents the state before a throw is made.
/// </summary>
/// <param name="ThrowPanel">Current throw panel state with pin positions.</param>
/// <param name="CurrentPlayerId">ID of the player about to throw.</param>
public record BeforeThrowState(
ThrowPanelSnapshot ThrowPanel,
Guid CurrentPlayerId
);
/// <summary>
/// Represents the state after a throw is made.
/// </summary>
/// <param name="ThrowPanel">Updated throw panel state after the throw.</param>
/// <param name="CurrentPlayerId">ID of the player who just threw.</param>
/// <param name="PinsKnocked">Number of pins knocked down in this throw.</param>
/// <param name="IsCircle">Whether a circle (Kranz) was scored.</param>
/// <param name="IsStrike">Whether all 9 pins were knocked down.</param>
/// <param name="IsGutter">Whether the throw was a gutter (Rinne).</param>
public record AfterThrowState(
ThrowPanelSnapshot ThrowPanel,
Guid CurrentPlayerId,
int PinsKnocked,
bool IsCircle,
bool IsStrike,
bool IsGutter
);
/// <summary>
/// Snapshot of throw panel state for game progress tracking.
/// </summary>
public record ThrowPanelSnapshot
{
/// <summary>
/// Status of all 9 pins (index 0 = pin 1, etc.).
/// </summary>
public PinStatus[] Pins { get; init; } = new PinStatus[9];
/// <summary>
/// Number of throws allowed per round.
/// </summary>
public int ThrowsPerRound { get; init; }
/// <summary>
/// Current throw count within the round.
/// </summary>
public int ThrowCounterPerRound { get; init; }
/// <summary>
/// Total throws made in the game.
/// </summary>
public int TotalThrowCounter { get; init; }
/// <summary>
/// Current throw mode.
/// </summary>
public ThrowMode ThrowMode { get; init; }
/// <summary>
/// Bell value for special scoring.
/// </summary>
public bool BellValue { get; init; }
/// <summary>
/// Creates snapshot from pin status array.
/// </summary>
public static ThrowPanelSnapshot FromPins(
PinStatus[] pins,
int throwsPerRound,
int throwCounterPerRound,
int totalThrowCounter,
ThrowMode throwMode,
bool bellValue) => new()
{
Pins = pins,
ThrowsPerRound = throwsPerRound,
ThrowCounterPerRound = throwCounterPerRound,
TotalThrowCounter = totalThrowCounter,
ThrowMode = throwMode,
BellValue = bellValue
};
}
/// <summary>
/// Extension methods for game progress calculations.
/// </summary>
public static class GameProgressExtensions
{
/// <summary>
/// Counts fallen pins in the snapshot.
/// </summary>
public static int PinCount(this ThrowPanelSnapshot snapshot)
=> snapshot.Pins.Count(p => p == PinStatus.Fallen);
/// <summary>
/// Counts standing pins in the snapshot.
/// </summary>
public static int StandingPinCount(this ThrowPanelSnapshot snapshot)
=> snapshot.Pins.Count(p => p == PinStatus.Standing);
/// <summary>
/// Checks if a circle (Kranz) was scored - 8 outer pins down, center standing.
/// Pin layout: 1=top, 5=center, 9=bottom
/// Circle: pins 1,2,3,4,6,7,8,9 down, pin 5 standing.
/// </summary>
public static bool IsCircle(this ThrowPanelSnapshot snapshot)
{
var pins = snapshot.Pins;
if (pins.Length != 9) return false;
// Pin 5 (index 4) must be standing
if (pins[4] != PinStatus.Standing) return false;
// All other pins must be fallen
return pins[0] == PinStatus.Fallen &&
pins[1] == PinStatus.Fallen &&
pins[2] == PinStatus.Fallen &&
pins[3] == PinStatus.Fallen &&
pins[5] == PinStatus.Fallen &&
pins[6] == PinStatus.Fallen &&
pins[7] == PinStatus.Fallen &&
pins[8] == PinStatus.Fallen;
}
/// <summary>
/// Checks if all 9 pins were knocked down (strike).
/// </summary>
public static bool IsStrike(this ThrowPanelSnapshot snapshot)
=> snapshot.Pins.All(p => p == PinStatus.Fallen);
/// <summary>
/// Checks if no pins were knocked down (gutter/Rinne).
/// </summary>
public static bool IsGutter(this ThrowPanelSnapshot snapshot)
=> snapshot.Pins.All(p => p == PinStatus.Standing);
/// <summary>
/// Checks if round is complete based on throw counter.
/// </summary>
public static bool IsRoundComplete(this ThrowPanelSnapshot snapshot)
=> snapshot.ThrowCounterPerRound >= snapshot.ThrowsPerRound;
/// <summary>
/// Creates after-throw state from before and after snapshots.
/// </summary>
public static AfterThrowState CreateAfterThrowState(
this ThrowPanelSnapshot afterThrow,
ThrowPanelSnapshot beforeThrow,
Guid playerId)
{
var pinsKnockedBefore = beforeThrow.PinCount();
var pinsKnockedAfter = afterThrow.PinCount();
var pinsKnocked = pinsKnockedAfter - pinsKnockedBefore;
return new AfterThrowState(
ThrowPanel: afterThrow,
CurrentPlayerId: playerId,
PinsKnocked: pinsKnocked,
IsCircle: afterThrow.IsCircle(),
IsStrike: afterThrow.IsStrike(),
IsGutter: pinsKnocked == 0
);
}
}
/// <summary>
/// Result of processing a throw in the game.
/// </summary>
public record ThrowResult
{
/// <summary>
/// Points scored in this throw.
/// </summary>
public int PointsScored { get; init; }
/// <summary>
/// Whether the round is complete and player should rotate.
/// </summary>
public bool ShouldRotatePlayer { get; init; }
/// <summary>
/// Whether the game has ended.
/// </summary>
public bool IsGameOver { get; init; }
/// <summary>
/// Optional winner ID if game is over.
/// </summary>
public Guid? WinnerId { get; init; }
/// <summary>
/// Trigger events that should fire (e.g., for penalties).
/// </summary>
public IReadOnlyList<TriggerEvent> Triggers { get; init; } = [];
}
/// <summary>
/// Represents a trigger event that should fire.
/// </summary>
public record TriggerEvent
{
/// <summary>
/// Type of trigger to fire.
/// </summary>
public required string TriggerType { get; init; }
/// <summary>
/// Person receiving the penalty/reward.
/// </summary>
public required Guid PersonId { get; init; }
/// <summary>
/// Multiplier for the expense (e.g., remaining points in Shit game).
/// </summary>
public int Multiplier { get; init; } = 1;
}

View File

@ -0,0 +1,130 @@
namespace Koogle.Application.Games;
/// <summary>
/// Defines game-specific logic for processing throws and managing game state.
/// Each game type (Training, Shit, etc.) implements this interface.
/// </summary>
public interface IGameLogicService
{
/// <summary>
/// Creates initial game model with setup configuration.
/// </summary>
/// <param name="playerIds">IDs of participating players.</param>
/// <param name="setupOptions">Game-specific setup options (JSON or typed object).</param>
/// <returns>Initial game model object.</returns>
object CreateInitialModel(Guid[] playerIds, object? setupOptions);
/// <summary>
/// Processes a throw and updates the game model.
/// </summary>
/// <param name="gameModel">Current game model.</param>
/// <param name="afterThrow">State after the throw was made.</param>
/// <returns>Tuple of updated game model and throw result.</returns>
(object UpdatedModel, ThrowResult Result) ProcessThrow(object gameModel, AfterThrowState afterThrow);
/// <summary>
/// Determines if the game should end based on current state.
/// </summary>
/// <param name="gameModel">Current game model.</param>
/// <returns>True if game should end, along with optional winner ID.</returns>
(bool IsGameOver, Guid? WinnerId) CheckGameEnd(object gameModel);
/// <summary>
/// Gets summary statistics for display on the board.
/// </summary>
/// <param name="gameModel">Current game model.</param>
/// <returns>Dictionary of player ID to their stats.</returns>
IReadOnlyDictionary<Guid, PlayerStatsSummary> GetPlayerStats(object gameModel);
/// <summary>
/// Validates setup options before starting the game.
/// </summary>
/// <param name="setupOptions">Setup options to validate.</param>
/// <returns>Validation result with errors if any.</returns>
GameSetupValidationResult ValidateSetup(object? setupOptions);
}
/// <summary>
/// Summary of a player's statistics for board display.
/// </summary>
public record PlayerStatsSummary
{
/// <summary>
/// Total throws made by the player.
/// </summary>
public int ThrowCount { get; init; }
/// <summary>
/// Total pins knocked down.
/// </summary>
public int PinCount { get; init; }
/// <summary>
/// Number of circles (Kranz) scored.
/// </summary>
public int CircleCount { get; init; }
/// <summary>
/// Number of strikes (all 9 pins) scored.
/// </summary>
public int StrikeCount { get; init; }
/// <summary>
/// Current points/score (game-specific meaning).
/// </summary>
public int CurrentPoints { get; init; }
/// <summary>
/// Average pins per throw.
/// </summary>
public double Average => ThrowCount > 0 ? (double)PinCount / ThrowCount : 0;
/// <summary>
/// Custom display values for game-specific stats.
/// </summary>
public IReadOnlyDictionary<string, object> CustomStats { get; init; } = new Dictionary<string, object>();
}
/// <summary>
/// Result of game setup validation.
/// </summary>
public record GameSetupValidationResult
{
/// <summary>
/// Whether the setup is valid.
/// </summary>
public bool IsValid { get; init; }
/// <summary>
/// Validation errors if any.
/// </summary>
public IReadOnlyList<string> Errors { get; init; } = [];
/// <summary>
/// Warnings that don't prevent starting but should be shown.
/// </summary>
public IReadOnlyList<string> Warnings { get; init; } = [];
/// <summary>
/// Creates a valid result.
/// </summary>
public static GameSetupValidationResult Valid() => new() { IsValid = true };
/// <summary>
/// Creates a valid result with warnings.
/// </summary>
public static GameSetupValidationResult ValidWithWarnings(params string[] warnings) => new()
{
IsValid = true,
Warnings = warnings
};
/// <summary>
/// Creates an invalid result with errors.
/// </summary>
public static GameSetupValidationResult Invalid(params string[] errors) => new()
{
IsValid = false,
Errors = errors
};
}

View File

@ -0,0 +1,37 @@
namespace Koogle.Domain.Interfaces;
/// <summary>
/// Defines a game type with its components and logic.
/// </summary>
public interface IGameDefinition
{
/// <summary>
/// Internal name for the game type (e.g., "Training", "Shit").
/// </summary>
string Name { get; }
/// <summary>
/// Display name shown to users (e.g., "Kegel-Training", "Scheiss-Spiel").
/// </summary>
string DisplayName { get; }
/// <summary>
/// Blazor component type for game setup configuration.
/// </summary>
Type SetupComponentType { get; }
/// <summary>
/// Blazor component type for the game board/scoreboard display.
/// </summary>
Type BoardComponentType { get; }
/// <summary>
/// Service type that implements game-specific logic.
/// </summary>
Type GameLogicServiceType { get; }
/// <summary>
/// Type of the game-specific model class.
/// </summary>
Type GameModelType { get; }
}