From 0207c5fe80520130179b8f68e9f732d213cf27c9 Mon Sep 17 00:00:00 2001 From: beo3000 Date: Fri, 26 Dec 2025 14:38:21 +0100 Subject: [PATCH] Add Game Definition Framework (Phase H3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/Koogle.Application/DependencyInjection.cs | 8 +- .../Games/GameDefinitionRegistry.cs | 177 ++++++++++++++ src/Koogle.Application/Games/GameProgress.cs | 220 ++++++++++++++++++ .../Games/IGameLogicService.cs | 130 +++++++++++ .../Interfaces/IGameDefinition.cs | 37 +++ 5 files changed, 571 insertions(+), 1 deletion(-) create mode 100644 src/Koogle.Application/Games/GameDefinitionRegistry.cs create mode 100644 src/Koogle.Application/Games/GameProgress.cs create mode 100644 src/Koogle.Application/Games/IGameLogicService.cs create mode 100644 src/Koogle.Domain/Interfaces/IGameDefinition.cs diff --git a/src/Koogle.Application/DependencyInjection.cs b/src/Koogle.Application/DependencyInjection.cs index 1ff724a..cd397bd 100644 --- a/src/Koogle.Application/DependencyInjection.cs +++ b/src/Koogle.Application/DependencyInjection.cs @@ -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(); services.AddScoped(); + // Note: Game types are registered in Koogle.Web where Blazor components are defined + // Use services.AddGameType() to register game types + return services; } } diff --git a/src/Koogle.Application/Games/GameDefinitionRegistry.cs b/src/Koogle.Application/Games/GameDefinitionRegistry.cs new file mode 100644 index 0000000..577ea11 --- /dev/null +++ b/src/Koogle.Application/Games/GameDefinitionRegistry.cs @@ -0,0 +1,177 @@ +using Koogle.Domain.Interfaces; +using Microsoft.Extensions.DependencyInjection; +using System.Text.Json; + +namespace Koogle.Application.Games; + +/// +/// Registry for game definitions. Manages available game types. +/// +public class GameDefinitionRegistry +{ + private readonly Dictionary _definitions = new(StringComparer.OrdinalIgnoreCase); + private readonly IServiceProvider _serviceProvider; + + /// + /// Creates new registry with service provider for resolving game logic services. + /// + public GameDefinitionRegistry(IServiceProvider serviceProvider) + { + _serviceProvider = serviceProvider; + } + + /// + /// Registers a game definition. + /// + public void Register(IGameDefinition definition) + { + _definitions[definition.Name] = definition; + } + + /// + /// Gets all registered game definitions. + /// + public IReadOnlyList GetAll() => _definitions.Values.ToList(); + + /// + /// Gets a game definition by name. + /// + /// Game type name (e.g., "Training"). + /// Game definition or null if not found. + public IGameDefinition? Get(string name) + => _definitions.TryGetValue(name, out var def) ? def : null; + + /// + /// Gets a game definition by name, throwing if not found. + /// + public IGameDefinition GetRequired(string name) + => Get(name) ?? throw new InvalidOperationException($"Game type '{name}' is not registered."); + + /// + /// Resolves the game logic service for a game type. + /// + /// Game type name. + /// Game logic service instance. + public IGameLogicService GetLogicService(string name) + { + var definition = GetRequired(name); + return (IGameLogicService)_serviceProvider.GetRequiredService(definition.GameLogicServiceType); + } + + /// + /// Checks if a game type is registered. + /// + public bool IsRegistered(string name) => _definitions.ContainsKey(name); +} + +/// +/// Factory for deserializing polymorphic game models. +/// +public static class GameModelFactory +{ + private static readonly Dictionary _modelTypes = new(StringComparer.OrdinalIgnoreCase); + + /// + /// Registers a game model type for deserialization. + /// + public static void RegisterModelType(string gameType, Type modelType) + { + _modelTypes[gameType] = modelType; + } + + /// + /// Deserializes a game model from JSON based on game type. + /// + /// JSON string containing the game model. + /// Game type name for polymorphic deserialization. + /// Deserialized game model object. + 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}"); + } + + /// + /// Serializes a game model to JSON. + /// + /// Game model object. + /// JSON string. + public static string Serialize(object model) + { + return JsonSerializer.Serialize(model, model.GetType(), JsonSerializerOptions); + } + + /// + /// JSON serializer options for game models. + /// + public static JsonSerializerOptions JsonSerializerOptions { get; } = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = false + }; +} + +/// +/// Extension methods for registering game definitions in DI. +/// +public static class GameRegistrationExtensions +{ + /// + /// Adds game framework services to DI. + /// + public static IServiceCollection AddGameFramework(this IServiceCollection services) + { + services.AddSingleton(); + return services; + } + + /// + /// Registers a game definition and its logic service. + /// + /// Game definition type. + /// Game logic service type. + public static IServiceCollection AddGameType(this IServiceCollection services) + where TDefinition : class, IGameDefinition, new() + where TLogicService : class, IGameLogicService + { + // Register the logic service + services.AddScoped(); + services.AddScoped(typeof(TLogicService), typeof(TLogicService)); + + // Register initializer to add definition to registry + services.AddTransient(sp => + new GameTypeInitializer()); + + return services; + } +} + +/// +/// Interface for game type initialization. +/// +public interface IGameTypeInitializer +{ + /// + /// Initializes the game type by registering it with the registry. + /// + void Initialize(GameDefinitionRegistry registry); +} + +/// +/// Initializer for a specific game type. +/// +internal class GameTypeInitializer : IGameTypeInitializer + where TDefinition : class, IGameDefinition, new() +{ + public void Initialize(GameDefinitionRegistry registry) + { + var definition = new TDefinition(); + registry.Register(definition); + GameModelFactory.RegisterModelType(definition.Name, definition.GameModelType); + } +} diff --git a/src/Koogle.Application/Games/GameProgress.cs b/src/Koogle.Application/Games/GameProgress.cs new file mode 100644 index 0000000..98ab5b3 --- /dev/null +++ b/src/Koogle.Application/Games/GameProgress.cs @@ -0,0 +1,220 @@ +using Koogle.Domain.Enums; + +namespace Koogle.Application.Games; + +/// +/// Represents the state before a throw is made. +/// +/// Current throw panel state with pin positions. +/// ID of the player about to throw. +public record BeforeThrowState( + ThrowPanelSnapshot ThrowPanel, + Guid CurrentPlayerId +); + +/// +/// Represents the state after a throw is made. +/// +/// Updated throw panel state after the throw. +/// ID of the player who just threw. +/// Number of pins knocked down in this throw. +/// Whether a circle (Kranz) was scored. +/// Whether all 9 pins were knocked down. +/// Whether the throw was a gutter (Rinne). +public record AfterThrowState( + ThrowPanelSnapshot ThrowPanel, + Guid CurrentPlayerId, + int PinsKnocked, + bool IsCircle, + bool IsStrike, + bool IsGutter +); + +/// +/// Snapshot of throw panel state for game progress tracking. +/// +public record ThrowPanelSnapshot +{ + /// + /// Status of all 9 pins (index 0 = pin 1, etc.). + /// + public PinStatus[] Pins { get; init; } = new PinStatus[9]; + + /// + /// Number of throws allowed per round. + /// + public int ThrowsPerRound { get; init; } + + /// + /// Current throw count within the round. + /// + public int ThrowCounterPerRound { get; init; } + + /// + /// Total throws made in the game. + /// + public int TotalThrowCounter { get; init; } + + /// + /// Current throw mode. + /// + public ThrowMode ThrowMode { get; init; } + + /// + /// Bell value for special scoring. + /// + public bool BellValue { get; init; } + + /// + /// Creates snapshot from pin status array. + /// + 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 + }; +} + +/// +/// Extension methods for game progress calculations. +/// +public static class GameProgressExtensions +{ + /// + /// Counts fallen pins in the snapshot. + /// + public static int PinCount(this ThrowPanelSnapshot snapshot) + => snapshot.Pins.Count(p => p == PinStatus.Fallen); + + /// + /// Counts standing pins in the snapshot. + /// + public static int StandingPinCount(this ThrowPanelSnapshot snapshot) + => snapshot.Pins.Count(p => p == PinStatus.Standing); + + /// + /// 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. + /// + 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; + } + + /// + /// Checks if all 9 pins were knocked down (strike). + /// + public static bool IsStrike(this ThrowPanelSnapshot snapshot) + => snapshot.Pins.All(p => p == PinStatus.Fallen); + + /// + /// Checks if no pins were knocked down (gutter/Rinne). + /// + public static bool IsGutter(this ThrowPanelSnapshot snapshot) + => snapshot.Pins.All(p => p == PinStatus.Standing); + + /// + /// Checks if round is complete based on throw counter. + /// + public static bool IsRoundComplete(this ThrowPanelSnapshot snapshot) + => snapshot.ThrowCounterPerRound >= snapshot.ThrowsPerRound; + + /// + /// Creates after-throw state from before and after snapshots. + /// + 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 + ); + } +} + +/// +/// Result of processing a throw in the game. +/// +public record ThrowResult +{ + /// + /// Points scored in this throw. + /// + public int PointsScored { get; init; } + + /// + /// Whether the round is complete and player should rotate. + /// + public bool ShouldRotatePlayer { get; init; } + + /// + /// Whether the game has ended. + /// + public bool IsGameOver { get; init; } + + /// + /// Optional winner ID if game is over. + /// + public Guid? WinnerId { get; init; } + + /// + /// Trigger events that should fire (e.g., for penalties). + /// + public IReadOnlyList Triggers { get; init; } = []; +} + +/// +/// Represents a trigger event that should fire. +/// +public record TriggerEvent +{ + /// + /// Type of trigger to fire. + /// + public required string TriggerType { get; init; } + + /// + /// Person receiving the penalty/reward. + /// + public required Guid PersonId { get; init; } + + /// + /// Multiplier for the expense (e.g., remaining points in Shit game). + /// + public int Multiplier { get; init; } = 1; +} diff --git a/src/Koogle.Application/Games/IGameLogicService.cs b/src/Koogle.Application/Games/IGameLogicService.cs new file mode 100644 index 0000000..04814f7 --- /dev/null +++ b/src/Koogle.Application/Games/IGameLogicService.cs @@ -0,0 +1,130 @@ +namespace Koogle.Application.Games; + +/// +/// Defines game-specific logic for processing throws and managing game state. +/// Each game type (Training, Shit, etc.) implements this interface. +/// +public interface IGameLogicService +{ + /// + /// Creates initial game model with setup configuration. + /// + /// IDs of participating players. + /// Game-specific setup options (JSON or typed object). + /// Initial game model object. + object CreateInitialModel(Guid[] playerIds, object? setupOptions); + + /// + /// Processes a throw and updates the game model. + /// + /// Current game model. + /// State after the throw was made. + /// Tuple of updated game model and throw result. + (object UpdatedModel, ThrowResult Result) ProcessThrow(object gameModel, AfterThrowState afterThrow); + + /// + /// Determines if the game should end based on current state. + /// + /// Current game model. + /// True if game should end, along with optional winner ID. + (bool IsGameOver, Guid? WinnerId) CheckGameEnd(object gameModel); + + /// + /// Gets summary statistics for display on the board. + /// + /// Current game model. + /// Dictionary of player ID to their stats. + IReadOnlyDictionary GetPlayerStats(object gameModel); + + /// + /// Validates setup options before starting the game. + /// + /// Setup options to validate. + /// Validation result with errors if any. + GameSetupValidationResult ValidateSetup(object? setupOptions); +} + +/// +/// Summary of a player's statistics for board display. +/// +public record PlayerStatsSummary +{ + /// + /// Total throws made by the player. + /// + public int ThrowCount { get; init; } + + /// + /// Total pins knocked down. + /// + public int PinCount { get; init; } + + /// + /// Number of circles (Kranz) scored. + /// + public int CircleCount { get; init; } + + /// + /// Number of strikes (all 9 pins) scored. + /// + public int StrikeCount { get; init; } + + /// + /// Current points/score (game-specific meaning). + /// + public int CurrentPoints { get; init; } + + /// + /// Average pins per throw. + /// + public double Average => ThrowCount > 0 ? (double)PinCount / ThrowCount : 0; + + /// + /// Custom display values for game-specific stats. + /// + public IReadOnlyDictionary CustomStats { get; init; } = new Dictionary(); +} + +/// +/// Result of game setup validation. +/// +public record GameSetupValidationResult +{ + /// + /// Whether the setup is valid. + /// + public bool IsValid { get; init; } + + /// + /// Validation errors if any. + /// + public IReadOnlyList Errors { get; init; } = []; + + /// + /// Warnings that don't prevent starting but should be shown. + /// + public IReadOnlyList Warnings { get; init; } = []; + + /// + /// Creates a valid result. + /// + public static GameSetupValidationResult Valid() => new() { IsValid = true }; + + /// + /// Creates a valid result with warnings. + /// + public static GameSetupValidationResult ValidWithWarnings(params string[] warnings) => new() + { + IsValid = true, + Warnings = warnings + }; + + /// + /// Creates an invalid result with errors. + /// + public static GameSetupValidationResult Invalid(params string[] errors) => new() + { + IsValid = false, + Errors = errors + }; +} diff --git a/src/Koogle.Domain/Interfaces/IGameDefinition.cs b/src/Koogle.Domain/Interfaces/IGameDefinition.cs new file mode 100644 index 0000000..1976806 --- /dev/null +++ b/src/Koogle.Domain/Interfaces/IGameDefinition.cs @@ -0,0 +1,37 @@ +namespace Koogle.Domain.Interfaces; + +/// +/// Defines a game type with its components and logic. +/// +public interface IGameDefinition +{ + /// + /// Internal name for the game type (e.g., "Training", "Shit"). + /// + string Name { get; } + + /// + /// Display name shown to users (e.g., "Kegel-Training", "Scheiss-Spiel"). + /// + string DisplayName { get; } + + /// + /// Blazor component type for game setup configuration. + /// + Type SetupComponentType { get; } + + /// + /// Blazor component type for the game board/scoreboard display. + /// + Type BoardComponentType { get; } + + /// + /// Service type that implements game-specific logic. + /// + Type GameLogicServiceType { get; } + + /// + /// Type of the game-specific model class. + /// + Type GameModelType { get; } +}