diff --git a/src/Koogle.Application/Games/FoxHunt/FoxHuntGameDefinition.cs b/src/Koogle.Application/Games/FoxHunt/FoxHuntGameDefinition.cs new file mode 100644 index 0000000..5f98eac --- /dev/null +++ b/src/Koogle.Application/Games/FoxHunt/FoxHuntGameDefinition.cs @@ -0,0 +1,32 @@ +using Koogle.Application.Games.Shit; +using Koogle.Domain.Interfaces; + +namespace Koogle.Application.Games.FoxHunt +{ + /// + /// Game definition for Fuchsjagd-Spiel (Fox Hunt) game type. + /// One Player starts as fox, and all others try to hunt him. + /// + public class FoxHuntGameDefinition : IGameDefinition + { + /// + public string Name => "FoxHunt"; + + /// + public string DisplayName => "Fuchsjagd"; + + /// + public Type SetupComponentType => Type.GetType( + "Koogle.Web.Components.Game.FoxHunt.FoxSetup, Koogle.Web")!; + + /// + public Type BoardComponentType => Type.GetType( + "Koogle.Web.Components.Game.FoxHunt.FoxBoard, Koogle.Web")!; + + /// + public Type GameLogicServiceType => typeof(FoxHuntGameLogicService); + + /// + public Type GameModelType => typeof(FoxHuntGameModel); + } +} diff --git a/src/Koogle.Application/Games/FoxHunt/FoxHuntGameLogicService.cs b/src/Koogle.Application/Games/FoxHunt/FoxHuntGameLogicService.cs new file mode 100644 index 0000000..1156dd9 --- /dev/null +++ b/src/Koogle.Application/Games/FoxHunt/FoxHuntGameLogicService.cs @@ -0,0 +1,272 @@ +using Koogle.Application.Games.DeathBox; +using Koogle.Application.Interfaces; +using Koogle.Domain.Enums; +using Microsoft.Extensions.DependencyInjection; +using Org.BouncyCastle.Cms; +using System; +using System.Text.Json; + +namespace Koogle.Application.Games.FoxHunt +{ + /// + /// Game logic service for Fuchsjagd-Spiel (Fox Hunt) game type. + /// One Player starts as fox, and all others try to hunt him. + /// + public class FoxHuntGameLogicService : IGameLogicService + { + private readonly IServiceProvider _serviceProvider; + public FoxHuntGameLogicService(IServiceProvider serviceProvider) + { + _serviceProvider = serviceProvider; + } + + public object CreateInitialModel(Guid[] playerIds, object? setupOptions) + { + var options = ParseSetupOptions(setupOptions); + + var playerOrder = playerIds.ToArray(); + + var playerStates = playerIds.ToDictionary( + id => id, + _ => new FoxHuntPlayerState()); + + return new FoxHuntGameModel + { + FoxIndex = 0, // aktueller Fuchs (Index in PlayerOrder) + NonFoxIndex = 0, // Index für Nicht-Fuchs-Spieler + FoxTurnsRemaining = 2, // erste 2 Fuchs-Züge + FoxTurn = false, + + LeadingThrows = options.LeadingThrows, + PlayerStates = playerStates, + PlayerOrder = playerOrder, + //CurrentPlayerIndex = 0, + //FoxLeadingThrowCount = 0, + //CurrentFoxIndex = 0, + WinnerId = null, + IsGameOver = false, + }; + } + + public (object UpdatedModel, ThrowResult Result) ProcessThrow(object gameModel, AfterThrowState afterThrow) + { + var model = CastModel(gameModel); + var playerId = afterThrow.CurrentPlayerId; + var pinsKnocked = afterThrow.PinsKnocked; + + if (model.IsGameOver) + { + return (model, new ThrowResult + { + PointsScored = 0, + ShouldRotatePlayer = false, + IsGameOver = true, + WinnerId = model.WinnerId + }); + } + + //var actualName = GetPlayerName(playerId).Result; + var playerStates = new Dictionary(model.PlayerStates); + //var eliminatedPlayers = new List(model.EliminatedPlayers); + var triggers = new List(); + + var foxId = model.PlayerOrder[model.FoxIndex]; + var isFoxThrow = playerId == foxId; + + + // 5. Check GAME END + bool isGameOver = false; + Guid? winnerId = null; + + + var nextPlayerId = GetNextId(model); + //var nextName = GetPlayerName(nextPlayerId).Result; + + // Update model + model = model with + { + FoxIndex = model.FoxIndex, + FoxTurn = model.FoxTurn, + FoxTurnsRemaining = model.FoxTurnsRemaining, + NonFoxIndex = model.NonFoxIndex + }; + + var result = new ThrowResult + { + PointsScored = pinsKnocked, + ShouldRotatePlayer = true, + IsGameOver = isGameOver, + WinnerId = winnerId, + Triggers = triggers, + Overrides = isGameOver ? null : new GameLogicOverrides + { + NextPlayerId = nextPlayerId + } + }; + + return (model, result); + } + + /// + /// algorithm: + /// A, A, B, A, C, A, D, + /// B, B, A, B, C, B, D, + /// C, C, A, C, B, C, D, + /// D, D, A, D, B, D, C + /// + /// + /// + private Guid GetNextId(FoxHuntGameModel model) + { + // Abbruch: alle waren einmal Fuchs + if (model.FoxIndex >= model.PlayerOrder.Length) + throw new InvalidOperationException("Alle Spieler waren bereits Fuchs."); + + Guid foxId = model.PlayerOrder[model.FoxIndex]; + + // 1️⃣ Fuchs ist 2x hintereinander dran + if (model.FoxTurnsRemaining > 0) + { + model.FoxTurnsRemaining--; + model.FoxTurn = false; // danach abwechselnd + return foxId; + } + + // 2️⃣ Abwechselnd Nicht-Fuchs → Fuchs + if (!model.FoxTurn) + { + // nächsten Nicht-Fuchs suchen + while (model.NonFoxIndex == model.FoxIndex) + model.NonFoxIndex++; + + if (model.NonFoxIndex < model.PlayerOrder.Length) + { + Guid next = model.PlayerOrder[model.NonFoxIndex]; + model.NonFoxIndex++; + model.FoxTurn = true; + return next; + } + else + { + // Alle Nicht-Füchse durch → neuer Fuchs + model.FoxIndex++; + model.NonFoxIndex = 0; + model.FoxTurnsRemaining = 2; + model.FoxTurn = true; + return GetNextId(model); + } + } + + // 3️⃣ Fuchs-Zug im Wechsel + model.FoxTurn = false; + return foxId; + } + + private static int GetNextActivePlayerIndex( + Guid[] playerOrder, + int currentIndex, + Dictionary playerStates) + { + return 0; + } + private async Task GetPlayerName(Guid playerId) + { + using (var scope = _serviceProvider.CreateScope()) + { + var _personService = scope.ServiceProvider.GetRequiredService(); + var persons = await _personService.GetAllAsync(); + var person = persons.FirstOrDefault(p => p.Id == playerId); + return person?.Name ?? "Unbekannt"; + } + } + public (bool IsGameOver, Guid? WinnerId) CheckGameEnd(object gameModel) + { + throw new NotImplementedException(); + } + + public IReadOnlyDictionary GetPlayerStats(object gameModel) + { + throw new NotImplementedException(); + } + + public GameSetupValidationResult ValidateSetup(object? setupOptions) + { + if (setupOptions is null) + { + return GameSetupValidationResult.Valid(); + } + + var options = ParseSetupOptions(setupOptions); + var errors = new List(); + + if (options.LeadingThrows < 2 || options.LeadingThrows > 4) + { + errors.Add("Vorsprung muss zwischen 6 und 12 liegen."); + } + + //TODO: validate player-count, at lease 2 players needed + + return errors.Count > 0 + ? GameSetupValidationResult.Invalid(errors.ToArray()) + : GameSetupValidationResult.Valid(); + } + + public IReadOnlyList GetAvailableActions(object gameModel, Guid currentPlayerId) + { + // FoxHunt has no custom actions + return []; + } + + public GameActionResult ExecuteAction(object gameModel, string actionId, Guid currentPlayerId, + IReadOnlyDictionary? parameters = null) + { + return GameActionResult.Failure($"Unknown action: {actionId}"); + } + + private static FoxHuntGameSetup ParseSetupOptions(object? setupOptions) + { + if (setupOptions is null) + { + return new FoxHuntGameSetup(); + } + + if (setupOptions is FoxHuntGameSetup typedSetup) + { + return typedSetup; + } + + if (setupOptions is JsonElement jsonElement) + { + try + { + return JsonSerializer.Deserialize( + jsonElement.GetRawText(), + GameModelFactory.JsonSerializerOptions) ?? new FoxHuntGameSetup(); + } + catch + { + return new FoxHuntGameSetup(); + } + } + + return new FoxHuntGameSetup(); + } + + private static FoxHuntGameModel CastModel(object gameModel) + { + if (gameModel is FoxHuntGameModel model) + { + return model; + } + + if (gameModel is JsonElement jsonElement) + { + return JsonSerializer.Deserialize( + jsonElement.GetRawText(), + GameModelFactory.JsonSerializerOptions)!; + } + + throw new InvalidOperationException($"Expected FoxHuntGameModel but got {gameModel.GetType().Name}"); + } + } +} diff --git a/src/Koogle.Application/Games/FoxHunt/FoxHuntGameModel.cs b/src/Koogle.Application/Games/FoxHunt/FoxHuntGameModel.cs new file mode 100644 index 0000000..6b36ead --- /dev/null +++ b/src/Koogle.Application/Games/FoxHunt/FoxHuntGameModel.cs @@ -0,0 +1,38 @@ +namespace Koogle.Application.Games.FoxHunt +{ + /// + /// Game model for Fuchsjagd-Spiel (Fox Hunt) game type. + /// One Player starts as fox, and all others try to hunt him. + /// + public record FoxHuntGameModel + { + /// + /// ID of the winner (first player to reach 0 points). + /// + public Guid? WinnerId { get; set; } + + public bool IsGameOver { get; set; } + //public int LeadingThrows { get; set; } + //public int FoxLeadingThrowCount { get; set; } + public Dictionary PlayerStates { get; set; } + public Guid[] PlayerOrder { get; set; } + + public int LeadingThrows { get; set; } // copy from setup model + //public int CurrentPlayerIndex { get; set; } + //public int CurrentFoxIndex { get; set; } + + + + public int FoxIndex = 0; // aktueller Fuchs (Index in PlayerOrder) + public int NonFoxIndex = 0; // Index für Nicht-Fuchs-Spieler + public int FoxTurnsRemaining = 2; // erste 2 Fuchs-Züge + public bool FoxTurn = true; // wer ist dran + + } + + public record FoxHuntPlayerState + { + + public int PinCount { get; set; } + } +} diff --git a/src/Koogle.Application/Games/FoxHunt/FoxHuntGameSetup.cs b/src/Koogle.Application/Games/FoxHunt/FoxHuntGameSetup.cs new file mode 100644 index 0000000..c9643b2 --- /dev/null +++ b/src/Koogle.Application/Games/FoxHunt/FoxHuntGameSetup.cs @@ -0,0 +1,34 @@ +using Koogle.Domain.Enums; + +namespace Koogle.Application.Games.FoxHunt +{ + /// + /// Setup model for Fuchsjagd-Spiel (Fox Hunt) game type. + /// One Player starts as fox, and all others try to hunt him. + /// + public record FoxHuntGameSetup : GameSetupModelBase + { + public override string GameType => "FoxHunt"; + + + /// + /// Leading throw the fox do, before the hunt begins + /// + public int LeadingThrows { get; init; } = 2; + + /// + /// Creates a default ShitGameSetup with specified base parameters. + /// + public static FoxHuntGameSetup Create( + ThrowMode throwMode, + int throwsPerRound, + ParticipantsMode participantsMode, + int leadingThrows = 2) => new() + { + ThrowMode = throwMode, + ThrowsPerRound = throwsPerRound, + ParticipantsMode = participantsMode, + LeadingThrows = leadingThrows + }; + } +} diff --git a/src/Koogle.Application/Games/IGameSetupModel.cs b/src/Koogle.Application/Games/IGameSetupModel.cs index a048ee3..5d9a8f3 100644 --- a/src/Koogle.Application/Games/IGameSetupModel.cs +++ b/src/Koogle.Application/Games/IGameSetupModel.cs @@ -11,6 +11,7 @@ namespace Koogle.Application.Games; [JsonDerivedType(typeof(Shit.ShitGameSetup), "Shit")] [JsonDerivedType(typeof(Training.TrainingGameSetup), "Training")] [JsonDerivedType(typeof(DeathBox.DeathBoxGameSetup), "DeathBox")] +[JsonDerivedType(typeof(FoxHunt.FoxHuntGameSetup), "FoxHunt")] public interface IGameSetupModel { /// diff --git a/src/Koogle.Web/Components/Game/FoxHunt/FoxBoard.razor b/src/Koogle.Web/Components/Game/FoxHunt/FoxBoard.razor new file mode 100644 index 0000000..eb18945 --- /dev/null +++ b/src/Koogle.Web/Components/Game/FoxHunt/FoxBoard.razor @@ -0,0 +1,213 @@ +@inherits Fluxor.Blazor.Web.Components.FluxorComponent + +@using System.Text.Json +@using Koogle.Application.Games.FoxHunt +@using Koogle.Web.Store.DayState +@using Koogle.Web.Store.GameState +@using Koogle.Application.Games; + +@implements IDisposable +@inject IState GameState +@inject IState DayState + +
+    @_debug
+
+ + + @if (_model == null) + { + + Spiel noch nicht gestartet. + + } + else + { + @* Game info header *@ + + + + Vorsprung für den Fuchs: + + @_model.LeadingThrows Vorsprung + + + + Füchse übrig: + + 0 + + + + + + @* Last throw info *@ + @* @if (_model.LastThrow != null) + { + + @GetLastThrowMessage() + + } *@ + + @* Winner announcement *@ + @if (_model.IsGameOver && _model.WinnerId.HasValue) + { + var winnerName = GetPlayerName(_model.WinnerId.Value); + + + + @winnerName hat überlebt! + + + } + + @* Players table *@ + + + Spieler + Status + Xs + Eier + Status + + + + @if (context.IsCurrentPlayer && !_model.IsGameOver) + { + + + @context.PlayerName + + + } + else + { + + @context.PlayerName + + } + + + + + + @if (context.IsWinner) + { + + SIEGER + + } + else if (context.IsEliminated) + { + + ☠️ Platz @context.FoxSurvivalOrder + + } + else if (context.IsCurrentPlayer) + { + + Am Zug + + } + + + + + @* Info footer *@ + @if (!_model.IsGameOver) + { + + + Überlebe als als Fuchs, mit dem größten Vorsprung! + + + } + } + + +@code { + private FoxHuntGameModel? _model; + private List _playerStats = []; + private string _debug; + + 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(); + + _model = null; + // _activePlayerCount = 0; + + var gameState = GameState.Value; + if (gameState.GameModel is FoxHuntGameModel model) + { + _model = model; + } + else if (gameState.GameModel is JsonElement jsonElement) + { + try + { + _model = JsonSerializer.Deserialize( + jsonElement.GetRawText(), + GameModelFactory.JsonSerializerOptions); + } + catch + { + return; + } + } + + _debug = JsonSerializer.Serialize(_model, new JsonSerializerOptions { WriteIndented = true }); + InvokeAsync(StateHasChanged); + + + if (_model?.PlayerStates == null || _model.PlayerOrder == null) + { + return; + } + + } + + public void Dispose() + { + GameState.StateChanged -= OnGameStateChanged; + } + + private string GetPlayerName(Guid playerId) + { + var person = DayState.Value.AvailablePersons.FirstOrDefault(p => p.Id == playerId); + return person?.Name ?? "Unbekannt"; + } + + private record PlayerStatsRow + { + public Guid PlayerId { get; init; } + public string PlayerName { get; init; } = ""; + public string Status { get; init; } = ""; + public bool IsWinner { get; init; } + public bool IsCurrentPlayer { get; init; } + public bool IsEliminated { get; init; } + public int FoxSurvivalOrder { get; init; } + } + +} diff --git a/src/Koogle.Web/Components/Game/FoxHunt/FoxSetup.razor b/src/Koogle.Web/Components/Game/FoxHunt/FoxSetup.razor new file mode 100644 index 0000000..ca4ca42 --- /dev/null +++ b/src/Koogle.Web/Components/Game/FoxHunt/FoxSetup.razor @@ -0,0 +1,121 @@ +@inject ITriggerService TriggerService +@using Koogle.Application.Games +@using Koogle.Application.Games.FoxHunt +@using Koogle.Application.Interfaces +@using Koogle.Domain.Enums +@implements Koogle.Application.Interfaces.IGameSetupControl + + + Fuchsjagd Einstellungen + + + + + + + @if (!_hasExpensePointTrigger) + { + + + Hinweis: Es ist kein "Strafpunkt"-Trigger konfiguriert. + Die Verlierer-Strafen werden nicht automatisch berechnet. + Gehe zu Stammdaten → Trigger, um einen ExpensePoint-Trigger einzurichten. + + + } + + + + + Spielregeln: +
    +
  • Jeder Spieler reihum ist einmal der Fuchs
  • +
  • Der Fuchs startet mit 'Anzahl-Würfe-Vorsprung' in die Vollen
  • +
  • Er sammelt möglichst viele Kegel und erarbeitet sich sich einen Vorsprung
  • +
  • Anschließend hat jeder weitere Spieler einen Wurf, abwechselnd mit dem Fuchs
  • +
  • Ziel der anderen Spieler ist es, den Fuchs zu fangen!
  • +
  • Ziel des Fuchses ist es, am Ende der Runde mindestens einen Punkt Vorsprung zu haben
  • +
  • Es gewinnt der Fuchs mit dem größten Vorsprung
  • +
+
+ +
+@code { + /// + /// Callback when setup options change. + /// + [Parameter] + public EventCallback OnOptionsChanged { get; set; } + + /// + /// Initial setup options. + /// + [Parameter] + public object? InitialOptions { get; set; } + + private bool _hasExpensePointTrigger = true; + + private FoxSetupOptionsInternal _options = new(); + + // Internal mutable class for two-way binding + private class FoxSetupOptionsInternal + { + public int LeadingThrows { get; set; } = 2; + + public ParticipantsMode ParticipantsMode { get; set; } = ParticipantsMode.GameLogic; + } + + protected override async Task OnInitializedAsync() + { + if (InitialOptions is FoxHuntGameSetup setup) + { + _options = new FoxSetupOptionsInternal + { + LeadingThrows = setup.LeadingThrows, + ParticipantsMode = setup.ParticipantsMode + }; + } + + // Check if ExpensePoint trigger is configured + _hasExpensePointTrigger = await TriggerService.HasExpensesForTriggerAsync(ExpenseTriggerType.ExpensePoint); + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + await NotifyOptionsChanged(); + } + } + + private async Task NotifyOptionsChanged() + { + var setup = new FoxHuntGameSetup + { + LeadingThrows = _options.LeadingThrows, + ParticipantsMode = _options.ParticipantsMode + }; + await OnOptionsChanged.InvokeAsync(setup); + } + + public IGameSetupModel GameSetupModel => CreateFoxSetup(); + + /// + /// Creates a default FoxHuntSetup with specified base parameters. + /// + private FoxHuntGameSetup CreateFoxSetup() + { + return FoxHuntGameSetup.Create( + throwMode: ThrowMode.Reposition, + throwsPerRound: 1, + participantsMode: ParticipantsMode.GameLogic, + leadingThrows: _options.LeadingThrows); + } + +} diff --git a/src/Koogle.Web/Components/Game/GameTypeSelector.razor b/src/Koogle.Web/Components/Game/GameTypeSelector.razor index fa77595..17525fe 100644 --- a/src/Koogle.Web/Components/Game/GameTypeSelector.razor +++ b/src/Koogle.Web/Components/Game/GameTypeSelector.razor @@ -92,6 +92,8 @@ { "Training" => Icons.Material.Filled.FitnessCenter, "Shit" => Icons.Material.Filled.Casino, + "DeathBox" => Icons.Material.Filled.Church, + "FoxHunt" => Icons.Material.Filled.DirectionsRun, _ => Icons.Material.Filled.SportsScore }; } diff --git a/src/Koogle.Web/Program.cs b/src/Koogle.Web/Program.cs index 5621584..7334f1e 100644 --- a/src/Koogle.Web/Program.cs +++ b/src/Koogle.Web/Program.cs @@ -18,6 +18,7 @@ using Microsoft.AspNetCore.Components.Authorization; using Microsoft.AspNetCore.Components.Server; using MudBlazor.Services; using System.Reflection; +using Koogle.Application.Games.FoxHunt; var builder = WebApplication.CreateBuilder(args); @@ -32,6 +33,7 @@ builder.Services.AddApplication(); builder.Services.AddGameType(); builder.Services.AddGameType(); builder.Services.AddGameType(); +builder.Services.AddGameType(); // SignalR for real-time game updates builder.Services.AddSignalR();