using System.Text.Json; using Koogle.Domain.Enums; namespace Koogle.Application.Games.ChristmasTree; /// /// Game logic service for the Christmas Tree (Tannenbaum) bowling game. /// Teams take turns throwing. Numbers are crossed from own tree first, /// then from all opponent trees if not available on own tree. /// Game ends when the last 5 is crossed. Team with most crossed numbers wins. /// public class ChristmasTreeGameLogicService : IGameLogicService { /// public object CreateInitialModel(Guid[] playerIds, object? setupOptions) { var options = ParseSetupOptions(setupOptions); if (options.Teams == null || options.Teams.Count < 2) { throw new InvalidOperationException("Christmas Tree game requires at least 2 teams."); } var treeStructure = ChristmasTreeGameSetup.GetTreeStructure(options.Variant); var teamTrees = new Dictionary(); for (int i = 0; i < options.Teams.Count; i++) { teamTrees[i] = new TeamTreeState { RemainingNumbers = new Dictionary(treeStructure), CrossedOutCount = 0, ThrowCount = 0, TotalPins = 0 }; } return new ChristmasTreeGameModel { Variant = options.Variant, TeamTrees = teamTrees, Teams = options.Teams.ToList(), CurrentTeamIndex = 0, CurrentPlayerIndexInTeam = 0, EnableContinueOnMiss = options.EnableContinueOnMiss, MaxContinueThrows = options.MaxContinueThrows, ContinueThrowsRemaining = 0, IsInContinueMode = false, OpponentSelectingTeamIndex = null, WinnerId = null, WinnerTeamIndex = null, IsGameOver = false, LastThrow = null }; } /// public (object UpdatedModel, ThrowResult Result) ProcessThrow(object gameModel, AfterThrowState afterThrow) { var model = this.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 }); } // Find player's team int playerTeamIndex = FindPlayerTeamIndex(model, playerId); if (playerTeamIndex < 0) playerTeamIndex = model.CurrentTeamIndex; var teamTrees = new Dictionary(model.TeamTrees); var currentTeamState = teamTrees[playerTeamIndex] with { ThrowCount = teamTrees[playerTeamIndex].ThrowCount + 1, TotalPins = teamTrees[playerTeamIndex].TotalPins + pinsKnocked }; teamTrees[playerTeamIndex] = currentTeamState; var triggers = new List(); var lastThrow = new ChristmasTreeLastThrow { PlayerId = playerId, TeamIndex = playerTeamIndex, PinsKnocked = pinsKnocked, WasGutter = afterThrow.IsGutter }; // Gutter or 0 pins = miss, no crossing if (pinsKnocked == 0) { lastThrow = lastThrow with { WasMiss = true }; if (model.IsInContinueMode) { return HandleContinueModeThrow(model, teamTrees, lastThrow, pinsKnocked, playerTeamIndex, false); } model = model with { TeamTrees = teamTrees, LastThrow = lastThrow }; return RotateToNextPlayer(model, triggers); } // Try to cross number from own tree first bool crossedOwn = TryCrossNumber(teamTrees, playerTeamIndex, pinsKnocked); if (crossedOwn) { lastThrow = lastThrow with { CrossedOwnTree = true }; teamTrees[playerTeamIndex] = teamTrees[playerTeamIndex] with { CrossedOutCount = teamTrees[playerTeamIndex].CrossedOutCount + 1 }; // Check if game ended (last 5 crossed) bool gameEnded = CheckAllFivesGone(teamTrees); if (gameEnded) { lastThrow = lastThrow with { TriggeredGameEnd = true }; return HandleGameEnd(model, teamTrees, lastThrow, triggers); } model = model with { TeamTrees = teamTrees, LastThrow = lastThrow, IsInContinueMode = false, ContinueThrowsRemaining = 0 }; return RotateToNextPlayer(model, triggers); } // Try to cross from opponent trees var crossedOpponents = TryCrossFromOpponents(teamTrees, playerTeamIndex, pinsKnocked); if (crossedOpponents.Count > 0) { lastThrow = lastThrow with { CrossedOpponentTrees = true, OpponentTeamsCrossed = crossedOpponents.ToArray() }; // Check if game ended bool gameEnded = CheckAllFivesGone(teamTrees); if (gameEnded) { lastThrow = lastThrow with { TriggeredGameEnd = true }; return HandleGameEnd(model, teamTrees, lastThrow, triggers); } model = model with { TeamTrees = teamTrees, LastThrow = lastThrow, IsInContinueMode = false, ContinueThrowsRemaining = 0 }; return RotateToNextPlayer(model, triggers); } // Number not available anywhere = miss lastThrow = lastThrow with { WasMiss = true }; if (model.EnableContinueOnMiss) { return HandleContinueModeThrow(model, teamTrees, lastThrow, pinsKnocked, playerTeamIndex, true); } model = model with { TeamTrees = teamTrees, LastThrow = lastThrow }; return RotateToNextPlayer(model, triggers); } private (object UpdatedModel, ThrowResult Result) HandleContinueModeThrow( ChristmasTreeGameModel model, Dictionary teamTrees, ChristmasTreeLastThrow lastThrow, int pinsKnocked, int playerTeamIndex, bool justEnteredContinueMode) { int throwsRemaining; if (justEnteredContinueMode) { // Just entered continue mode throwsRemaining = model.MaxContinueThrows - 1; // -1 because this throw counts } else { // Already in continue mode throwsRemaining = model.ContinueThrowsRemaining - 1; } if (throwsRemaining > 0) { // Still has throws remaining lastThrow = lastThrow with { StillInContinueMode = true, ContinueThrowsRemaining = throwsRemaining }; model = model with { TeamTrees = teamTrees, LastThrow = lastThrow, IsInContinueMode = true, ContinueThrowsRemaining = throwsRemaining }; // Don't rotate - same player continues return (model, new ThrowResult { PointsScored = pinsKnocked, ShouldRotatePlayer = false, IsGameOver = false, WinnerId = null, Triggers = [] }); } else { // Failed all continue throws - opponent can select a number // Find next opponent team int nextOpponentIndex = (playerTeamIndex + 1) % model.Teams.Count; model = model with { TeamTrees = teamTrees, LastThrow = lastThrow, IsInContinueMode = false, ContinueThrowsRemaining = 0, OpponentSelectingTeamIndex = nextOpponentIndex }; // Don't rotate yet - wait for opponent selection action return (model, new ThrowResult { PointsScored = pinsKnocked, ShouldRotatePlayer = false, IsGameOver = false, WinnerId = null, Triggers = [] }); } } private (object UpdatedModel, ThrowResult Result) HandleGameEnd( ChristmasTreeGameModel model, Dictionary teamTrees, ChristmasTreeLastThrow lastThrow, List triggers) { // Find winner (team with most crossed numbers) int winnerIndex = 0; int maxCrossed = 0; foreach (var (teamIndex, state) in teamTrees) { if (state.CrossedOutCount > maxCrossed) { maxCrossed = state.CrossedOutCount; winnerIndex = teamIndex; } } // Get winner ID (first player of winning team) Guid? winnerId = model.Teams[winnerIndex].PlayerIds.FirstOrDefault(); // Calculate penalties for losing teams foreach (var (teamIndex, state) in teamTrees) { if (teamIndex != winnerIndex) { int remainingNumbers = state.RemainingNumbers.Values.Sum(); if (remainingNumbers > 0) { // Each player in losing team pays per remaining number foreach (var playerId in model.Teams[teamIndex].PlayerIds) { triggers.Add(new TriggerEvent { TriggerType = ExpenseTriggerType.ExpensePoint.ToString(), PersonId = playerId, Multiplier = remainingNumbers }); } } } } model = model with { TeamTrees = teamTrees, LastThrow = lastThrow, IsGameOver = true, WinnerId = winnerId, WinnerTeamIndex = winnerIndex, IsInContinueMode = false, ContinueThrowsRemaining = 0, OpponentSelectingTeamIndex = null }; return (model, new ThrowResult { PointsScored = lastThrow.PinsKnocked, ShouldRotatePlayer = false, IsGameOver = true, WinnerId = winnerId, Triggers = triggers }); } private (object UpdatedModel, ThrowResult Result) RotateToNextPlayer( ChristmasTreeGameModel model, List triggers) { // Rotate to next team and player int nextTeamIndex = (model.CurrentTeamIndex + 1) % model.Teams.Count; int nextPlayerIndex = 0; if (nextTeamIndex == 0) { // Wrapped around - increment player index within teams // This assumes all teams have same number of players // For varying team sizes, we track per-team player index nextPlayerIndex = (model.CurrentPlayerIndexInTeam + 1) % Math.Max(1, model.Teams[nextTeamIndex].PlayerIds.Count); } else { nextPlayerIndex = model.CurrentPlayerIndexInTeam % Math.Max(1, model.Teams[nextTeamIndex].PlayerIds.Count); } var nextPlayerId = model.Teams[nextTeamIndex].PlayerIds.Count > nextPlayerIndex ? model.Teams[nextTeamIndex].PlayerIds[nextPlayerIndex] : model.Teams[nextTeamIndex].PlayerIds.FirstOrDefault(); model = model with { CurrentTeamIndex = nextTeamIndex, CurrentPlayerIndexInTeam = nextPlayerIndex }; return (model, new ThrowResult { PointsScored = model.LastThrow?.PinsKnocked ?? 0, ShouldRotatePlayer = true, IsGameOver = false, WinnerId = null, Triggers = triggers, Overrides = new GameLogicOverrides { NextPlayerId = nextPlayerId } }); } /// public (bool IsGameOver, Guid? WinnerId) CheckGameEnd(object gameModel) { var model = this.CastModel(gameModel); return (model.IsGameOver, model.WinnerId); } /// public IReadOnlyDictionary GetPlayerStats(object gameModel) { var model = this.CastModel(gameModel); var stats = new Dictionary(); foreach (var (teamIndex, team) in model.Teams.Select((t, i) => (i, t))) { var teamState = model.TeamTrees.GetValueOrDefault(teamIndex, new TeamTreeState()); foreach (var playerId in team.PlayerIds) { stats[playerId] = new PlayerStatsSummary { ThrowCount = teamState.ThrowCount, PinCount = teamState.TotalPins, CircleCount = 0, StrikeCount = 0, CurrentPoints = teamState.CrossedOutCount, CustomStats = new Dictionary { ["TeamIndex"] = teamIndex, ["TeamName"] = team.Name, ["CrossedOut"] = teamState.CrossedOutCount, ["Remaining"] = teamState.RemainingNumbers.Values.Sum(), ["IsWinner"] = model.WinnerTeamIndex == teamIndex, ["IsLoser"] = model.IsGameOver && model.WinnerTeamIndex != teamIndex } }; } } return stats; } /// public IReadOnlyList GetAvailableActions(object gameModel, Guid currentPlayerId) { var model = this.CastModel(gameModel); if (model.IsGameOver) return []; // Check if opponent needs to select a number if (model.OpponentSelectingTeamIndex.HasValue) { int selectingTeamIndex = model.OpponentSelectingTeamIndex.Value; var selectingTeam = model.Teams[selectingTeamIndex]; // Only players of the selecting team can execute this action if (selectingTeam.PlayerIds.Contains(currentPlayerId)) { // Build list of available numbers to select var availableNumbers = model.TeamTrees[selectingTeamIndex].RemainingNumbers .Where(kvp => kvp.Value > 0) .Select(kvp => kvp.Key) .OrderBy(n => n) .ToList(); if (availableNumbers.Count > 0) { return [ new GameActionDescriptor { ActionId = "selectNumber", Label = "Zahl wählen", Icon = "TouchApp", Color = "Primary", Tooltip = $"Wähle eine Zahl zum Streichen: {string.Join(", ", availableNumbers)}", Order = 1, Parameters = [ new ActionParameterDefinition { Key = "number", Label = "Wähle eine Zahl", Type = "select", Options = availableNumbers.Select(n => new ActionParameterOption { Value = n, Label = n.ToString() }).ToList() } ] } ]; } } } return []; } /// public GameActionResult ExecuteAction( object gameModel, string actionId, Guid currentPlayerId, IReadOnlyDictionary? parameters = null) { return actionId switch { "selectNumber" => ExecuteSelectNumber(gameModel, currentPlayerId, parameters), _ => GameActionResult.Failure($"Unknown action: {actionId}") }; } private GameActionResult ExecuteSelectNumber( object gameModel, Guid currentPlayerId, IReadOnlyDictionary? parameters) { var model = this.CastModel(gameModel); if (!model.OpponentSelectingTeamIndex.HasValue) { return GameActionResult.Failure("No number selection pending."); } int selectingTeamIndex = model.OpponentSelectingTeamIndex.Value; var selectingTeam = model.Teams[selectingTeamIndex]; if (!selectingTeam.PlayerIds.Contains(currentPlayerId)) { return GameActionResult.Failure("Only the opponent team can select a number."); } // Get selected number from parameters if (parameters == null || !parameters.TryGetValue("number", out var numberObj)) { return GameActionResult.Failure("No number specified."); } int selectedNumber; if (numberObj is int num) { selectedNumber = num; } else if (numberObj is long longNum) { selectedNumber = (int)longNum; } else if (numberObj is JsonElement jsonElement && jsonElement.TryGetInt32(out var jsonNum)) { selectedNumber = jsonNum; } else { return GameActionResult.Failure($"Invalid number format: {numberObj}"); } // Validate number is available var teamTrees = new Dictionary(model.TeamTrees); var teamState = teamTrees[selectingTeamIndex]; if (!teamState.RemainingNumbers.TryGetValue(selectedNumber, out var remaining) || remaining <= 0) { return GameActionResult.Failure($"Number {selectedNumber} is not available on your tree."); } // Cross the number var updatedNumbers = new Dictionary(teamState.RemainingNumbers) { [selectedNumber] = remaining - 1 }; teamTrees[selectingTeamIndex] = teamState with { RemainingNumbers = updatedNumbers, CrossedOutCount = teamState.CrossedOutCount + 1 }; // Check for game end bool gameEnded = CheckAllFivesGone(teamTrees); var triggers = new List(); if (gameEnded) { // Find winner int winnerIndex = 0; int maxCrossed = 0; foreach (var (teamIndex, state) in teamTrees) { if (state.CrossedOutCount > maxCrossed) { maxCrossed = state.CrossedOutCount; winnerIndex = teamIndex; } } Guid? winnerId = model.Teams[winnerIndex].PlayerIds.FirstOrDefault(); // Calculate penalties foreach (var (teamIndex, state) in teamTrees) { if (teamIndex != winnerIndex) { int remainingNumbers = state.RemainingNumbers.Values.Sum(); if (remainingNumbers > 0) { foreach (var playerId in model.Teams[teamIndex].PlayerIds) { triggers.Add(new TriggerEvent { TriggerType = ExpenseTriggerType.ExpensePoint.ToString(), PersonId = playerId, Multiplier = remainingNumbers }); } } } } model = model with { TeamTrees = teamTrees, OpponentSelectingTeamIndex = null, IsGameOver = true, WinnerId = winnerId, WinnerTeamIndex = winnerIndex }; return GameActionResult.SuccessResult( model, shouldRotate: false, isGameOver: true, winnerId: winnerId, triggers: triggers); } // Clear selection state and rotate to next player int nextTeamIndex = (model.CurrentTeamIndex + 1) % model.Teams.Count; int nextPlayerIndex = model.CurrentPlayerIndexInTeam; if (nextTeamIndex == 0) { nextPlayerIndex = (nextPlayerIndex + 1) % Math.Max(1, model.Teams[nextTeamIndex].PlayerIds.Count); } else { nextPlayerIndex = nextPlayerIndex % Math.Max(1, model.Teams[nextTeamIndex].PlayerIds.Count); } var nextPlayerId = model.Teams[nextTeamIndex].PlayerIds.Count > nextPlayerIndex ? model.Teams[nextTeamIndex].PlayerIds[nextPlayerIndex] : model.Teams[nextTeamIndex].PlayerIds.FirstOrDefault(); model = model with { TeamTrees = teamTrees, OpponentSelectingTeamIndex = null, CurrentTeamIndex = nextTeamIndex, CurrentPlayerIndexInTeam = nextPlayerIndex }; return GameActionResult.SuccessResult( model, shouldRotate: true, isGameOver: false, winnerId: null, triggers: triggers); } /// public GameSetupValidationResult ValidateSetup(object? setupOptions) { if (setupOptions is null) { return GameSetupValidationResult.Invalid(["Setup options required."]); } var options = ParseSetupOptions(setupOptions); var errors = new List(); if (options.Teams == null || options.Teams.Count < 2) { errors.Add("Mindestens 2 Mannschaften erforderlich."); } else { foreach (var team in options.Teams) { if (team.PlayerIds.Count == 0) { errors.Add($"Mannschaft '{team.Name}' hat keine Spieler."); } } } if (options.MaxContinueThrows < 1 || options.MaxContinueThrows > 10) { errors.Add("Anzahl Weiterwürfe muss zwischen 1 und 10 liegen."); } return errors.Count > 0 ? GameSetupValidationResult.Invalid(errors.ToArray()) : GameSetupValidationResult.Valid(); } #region Helper Methods private static int FindPlayerTeamIndex(ChristmasTreeGameModel model, Guid playerId) { for (int i = 0; i < model.Teams.Count; i++) { if (model.Teams[i].PlayerIds.Contains(playerId)) return i; } return -1; } private static bool TryCrossNumber(Dictionary teamTrees, int teamIndex, int number) { var state = teamTrees[teamIndex]; if (state.RemainingNumbers.TryGetValue(number, out var remaining) && remaining > 0) { var updatedNumbers = new Dictionary(state.RemainingNumbers) { [number] = remaining - 1 }; teamTrees[teamIndex] = state with { RemainingNumbers = updatedNumbers }; return true; } return false; } private static List TryCrossFromOpponents( Dictionary teamTrees, int playerTeamIndex, int number) { var crossedTeams = new List(); foreach (var (teamIndex, state) in teamTrees) { if (teamIndex == playerTeamIndex) continue; if (state.RemainingNumbers.TryGetValue(number, out var remaining) && remaining > 0) { var updatedNumbers = new Dictionary(state.RemainingNumbers) { [number] = remaining - 1 }; teamTrees[teamIndex] = state with { RemainingNumbers = updatedNumbers }; crossedTeams.Add(teamIndex); } } return crossedTeams; } private static bool CheckAllFivesGone(Dictionary teamTrees) { foreach (var state in teamTrees.Values) { if (state.RemainingNumbers.TryGetValue(5, out var remaining) && remaining > 0) { return false; } } return true; } private static ChristmasTreeGameSetup ParseSetupOptions(object? setupOptions) { if (setupOptions is null) { return new ChristmasTreeGameSetup(); } if (setupOptions is ChristmasTreeGameSetup typedSetup) { return typedSetup; } if (setupOptions is JsonElement jsonElement) { try { return JsonSerializer.Deserialize( jsonElement.GetRawText(), GameModelFactory.JsonSerializerOptions) ?? new ChristmasTreeGameSetup(); } catch { return new ChristmasTreeGameSetup(); } } return new ChristmasTreeGameSetup(); } #endregion }