using System.Text.Json; using Koogle.Domain.Enums; namespace Koogle.Application.Games.DeathBox; /// /// Game logic service for DeathBox (Totenkisten) game. /// Players collect marks and are eliminated when their coffin is full. /// Last player standing wins. /// public class DeathBoxGameLogicService : IGameLogicService { private static readonly Random Random = new(); /// public object CreateInitialModel(Guid[] playerIds, object? setupOptions) { var options = ParseSetupOptions(setupOptions); var playerOrder = options.RandomizePlayerOrder ? playerIds.OrderBy(_ => Random.Next()).ToArray() : playerIds.ToArray(); var playerStates = playerIds.ToDictionary( id => id, _ => new DeathBoxPlayerState()); return new DeathBoxGameModel { CoffinSize = options.CoffinSize, PlayerStates = playerStates, PlayerOrder = playerOrder, CurrentPlayerIndex = 0, PreviousPlayerId = null, EliminatedPlayers = [], WinnerId = null, IsGameOver = false, LastThrow = null }; } /// 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 playerStates = new Dictionary(model.PlayerStates); var eliminatedPlayers = new List(model.EliminatedPlayers); var triggers = new List(); var assignX = false; // Get current player state var currentState = playerStates[playerId]; currentState.ThrowCount++; currentState.TotalPins += pinsKnocked; // Track last throw info var lastThrow = new DeathBoxLastThrow { PlayerId = playerId, PinsKnocked = pinsKnocked, WasGutter = afterThrow.IsGutter, WasCleared = afterThrow.IsCleared }; // Determine if this is a new round (all 9 pins were standing before throw) bool isNewRound = IsNewRound(afterThrow); lastThrow = lastThrow with { IsNewRound = isNewRound }; // 1. NEW ROUND processing (when 9 pins were standing) if (isNewRound) { // Always collect X on new round //currentState.XCount++; //lastThrow = lastThrow with { EarnedX = true }; // Penalty for <3 pins on new round if (pinsKnocked < 3) { currentState.Marks++; lastThrow = lastThrow with { WasPenalty = true }; } // Check X conversion (3 X's -> 1 mark) if (currentState.XCount >= 3) { currentState.XCount = 0; currentState.Marks++; lastThrow = lastThrow with { ConvertedXsToMark = true }; } } // 2. GUTTER / NO WOOD processing (0 pins knocked) if (pinsKnocked == 0) { // Only add mark if not already penalized from new round rule // (new round with 0 pins = already got mark from <3 rule) if (!isNewRound) { currentState.Marks++; } if (afterThrow.IsGutter) { lastThrow = lastThrow with { WasGutter = true }; triggers.Add(new TriggerEvent { TriggerType = ExpenseTriggerType.Gutter.ToString(), PersonId = playerId, Multiplier = 1 }); } else { lastThrow = lastThrow with { WasNoWood = true }; triggers.Add(new TriggerEvent { TriggerType = ExpenseTriggerType.NoWood.ToString(), PersonId = playerId, Multiplier = 1 }); } } // 3. CLEARED processing (all remaining pins hit) //Guid? previousPlayerPenalizedId = null; bool previousPlayerEliminated = false; if (afterThrow.IsCleared /*&& !isNewRound*/) { // Current player gets egg currentState.EggCount++; lastThrow = lastThrow with { EarnedEgg = true }; // Check egg conversion (3 eggs -> -1 mark if possible) if (currentState.EggCount >= 3) { currentState.EggCount = 0; if (currentState.Marks > 0) { currentState.Marks--; lastThrow = lastThrow with { ConvertedEggsToRemoveMark = true }; } // Eggs expire if no marks to remove } // in case eggs have been earned, next player will get another X assignX = true; // Previous player gets mark (if exists and not eliminated) if (model.PreviousPlayerId.HasValue && playerStates.TryGetValue(model.PreviousPlayerId.Value, out var prevState) && !prevState.IsEliminated) { prevState.Marks++; lastThrow = lastThrow with { PreviousPlayerGotMark = true, PreviousPlayerPenalizedId = model.PreviousPlayerId.Value }; //previousPlayerPenalizedId = model.PreviousPlayerId.Value; // Check if previous player is eliminated if (prevState.Marks >= model.CoffinSize) { prevState.IsEliminated = true; eliminatedPlayers.Add(model.PreviousPlayerId.Value); previousPlayerEliminated = true; // Calculate penalty (remaining players after this elimination) int remainingPlayers = CountActivePlayers(playerStates); if (remainingPlayers > 0) { triggers.Add(new TriggerEvent { TriggerType = ExpenseTriggerType.ExpensePoint.ToString(), PersonId = model.PreviousPlayerId.Value, Multiplier = remainingPlayers }); } } } } lastThrow = lastThrow with { PreviousPlayerEliminated = previousPlayerEliminated }; // 4. Check ELIMINATION for current player bool currentPlayerEliminated = false; if (currentState.Marks >= model.CoffinSize && !currentState.IsEliminated) { currentState.IsEliminated = true; eliminatedPlayers.Add(playerId); currentPlayerEliminated = true; lastThrow = lastThrow with { PlayerEliminated = true }; // Calculate penalty (remaining players after this elimination) int remainingPlayers = CountActivePlayers(playerStates); if (remainingPlayers > 0) { triggers.Add(new TriggerEvent { TriggerType = ExpenseTriggerType.ExpensePoint.ToString(), PersonId = playerId, Multiplier = remainingPlayers }); } } // Update player states playerStates[playerId] = currentState; // 5. Check GAME END bool isGameOver = false; Guid? winnerId = null; int activePlayersCount = CountActivePlayers(playerStates); if (activePlayersCount <= 1) { isGameOver = true; winnerId = playerStates .Where(kvp => !kvp.Value.IsEliminated) .Select(kvp => kvp.Key) .FirstOrDefault(); } // 6. Determine next player (skip eliminated players) // Find current player's index in PlayerOrder based on actual playerId from throw int currentIndex = Array.IndexOf(model.PlayerOrder, playerId); if (currentIndex < 0) currentIndex = model.CurrentPlayerIndex; int nextPlayerIndex = GetNextActivePlayerIndex(model.PlayerOrder, currentIndex, playerStates); var nextPlayerId = model.PlayerOrder[nextPlayerIndex]; if (assignX) { var nextState = playerStates[nextPlayerId]; nextState.XCount ++; lastThrow = lastThrow with { EarnedX = true }; lastThrow = lastThrow with { EarnedX = true, NextPlayerPenalizedId = nextPlayerId }; // Check X conversion (3 X's -> 1 mark) if (nextState.XCount >= 3) { lastThrow = lastThrow with { NextPlayerGotMark = true, NextPlayerPenalizedId = nextPlayerId }; nextState.XCount = 0; nextState.Marks++; lastThrow = lastThrow with { ConvertedXsToMark = true }; } playerStates[nextPlayerId] = nextState; } // Update model model = model with { PlayerStates = playerStates, EliminatedPlayers = eliminatedPlayers, CurrentPlayerIndex = nextPlayerIndex, PreviousPlayerId = playerId, IsGameOver = isGameOver, WinnerId = winnerId, LastThrow = lastThrow }; var result = new ThrowResult { PointsScored = pinsKnocked, ShouldRotatePlayer = true, IsGameOver = isGameOver, WinnerId = winnerId, Triggers = triggers, Overrides = isGameOver ? null : new GameLogicOverrides { NextPlayerId = model.PlayerOrder[nextPlayerIndex] } }; return (model, result); } /// public (bool IsGameOver, Guid? WinnerId) CheckGameEnd(object gameModel) { var model = CastModel(gameModel); return (model.IsGameOver, model.WinnerId); } /// public IReadOnlyDictionary GetPlayerStats(object gameModel) { var model = CastModel(gameModel); return model.PlayerStates.ToDictionary( kvp => kvp.Key, kvp => new PlayerStatsSummary { ThrowCount = kvp.Value.ThrowCount, PinCount = kvp.Value.TotalPins, CircleCount = 0, StrikeCount = 0, CurrentPoints = kvp.Value.Marks, CustomStats = new Dictionary { ["Marks"] = kvp.Value.Marks, ["CoffinSize"] = model.CoffinSize, ["XCount"] = kvp.Value.XCount, ["EggCount"] = kvp.Value.EggCount, ["IsEliminated"] = kvp.Value.IsEliminated, ["IsWinner"] = model.WinnerId == kvp.Key, ["EliminationOrder"] = model.EliminatedPlayers.IndexOf(kvp.Key) + 1 } }); } /// public IReadOnlyList GetAvailableActions(object gameModel, Guid currentPlayerId) { // DeathBox has no custom actions return []; } /// public GameActionResult ExecuteAction( object gameModel, string actionId, Guid currentPlayerId, IReadOnlyDictionary? parameters = null) { return GameActionResult.Failure($"Unknown action: {actionId}"); } /// public GameSetupValidationResult ValidateSetup(object? setupOptions) { if (setupOptions is null) { return GameSetupValidationResult.Valid(); } var options = ParseSetupOptions(setupOptions); var errors = new List(); if (options.CoffinSize < 6 || options.CoffinSize > 12) { errors.Add("Sarggröße muss zwischen 6 und 12 liegen."); } return errors.Count > 0 ? GameSetupValidationResult.Invalid(errors.ToArray()) : GameSetupValidationResult.Valid(); } #region Helper Methods private static bool IsNewRound(AfterThrowState afterThrow) { // New round = 9 pins were standing before this throw // If it's a strike, all 9 were knocked down from 9 standing if (afterThrow.IsStrike) return true; // Otherwise check if standing pins before throw = 9 // We can infer this from: if all pins are now accounted for (knocked + still standing = 9) // and pins knocked equals the change var snapshot = afterThrow.ThrowPanel; var standingBefore = snapshot.StandingPinCount() + afterThrow.PinsKnocked; return standingBefore == 9; } private static int CountActivePlayers(Dictionary playerStates) { return playerStates.Values.Count(s => !s.IsEliminated); } private static int GetNextActivePlayerIndex( Guid[] playerOrder, int currentIndex, Dictionary playerStates) { int playerCount = playerOrder.Length; int nextIndex = (currentIndex + 1) % playerCount; // Find next non-eliminated player for (int i = 0; i < playerCount; i++) { var playerId = playerOrder[nextIndex]; if (playerStates.TryGetValue(playerId, out var state) && !state.IsEliminated) { return nextIndex; } nextIndex = (nextIndex + 1) % playerCount; } // All eliminated (shouldn't happen if game end is checked first) return currentIndex; } private static DeathBoxGameSetup ParseSetupOptions(object? setupOptions) { if (setupOptions is null) { return new DeathBoxGameSetup(); } if (setupOptions is DeathBoxGameSetup typedSetup) { return typedSetup; } if (setupOptions is JsonElement jsonElement) { try { return JsonSerializer.Deserialize( jsonElement.GetRawText(), GameModelFactory.JsonSerializerOptions) ?? new DeathBoxGameSetup(); } catch { return new DeathBoxGameSetup(); } } return new DeathBoxGameSetup(); } private static DeathBoxGameModel CastModel(object gameModel) { if (gameModel is DeathBoxGameModel model) { return model; } if (gameModel is JsonElement jsonElement) { return JsonSerializer.Deserialize( jsonElement.GetRawText(), GameModelFactory.JsonSerializerOptions)!; } throw new InvalidOperationException($"Expected DeathBoxGameModel but got {gameModel.GetType().Name}"); } #endregion }