From 31bbaaf70aeb765c1b64657a8df7f27cdc0fc1e2 Mon Sep 17 00:00:00 2001 From: beo3000 Date: Thu, 8 Jan 2026 16:23:00 +0100 Subject: [PATCH] added game christmas tree --- .../Games/ChristmasTree/ChristmasTree.md | 40 +- .../ChristmasTreeGameDefinition.cs | 39 + .../ChristmasTreeGameLogicService.cs | 766 ++++++++++++++++++ .../ChristmasTree/ChristmasTreeGameModel.cs | 161 ++++ .../ChristmasTree/ChristmasTreeGameSetup.cs | 96 +++ .../Games/IGameSetupModel.cs | 1 + .../ChristmasTree/ChristmasTreeBoard.razor | 296 +++++++ .../ChristmasTree/ChristmasTreeSetup.razor | 148 ++++ .../Components/Game/GameSetupDialog.razor | 10 +- src/Koogle.Web/Program.cs | 2 + 10 files changed, 1538 insertions(+), 21 deletions(-) create mode 100644 src/Koogle.Application/Games/ChristmasTree/ChristmasTreeGameDefinition.cs create mode 100644 src/Koogle.Application/Games/ChristmasTree/ChristmasTreeGameLogicService.cs create mode 100644 src/Koogle.Application/Games/ChristmasTree/ChristmasTreeGameModel.cs create mode 100644 src/Koogle.Application/Games/ChristmasTree/ChristmasTreeGameSetup.cs create mode 100644 src/Koogle.Web/Components/Game/ChristmasTree/ChristmasTreeBoard.razor create mode 100644 src/Koogle.Web/Components/Game/ChristmasTree/ChristmasTreeSetup.razor diff --git a/src/Koogle.Application/Games/ChristmasTree/ChristmasTree.md b/src/Koogle.Application/Games/ChristmasTree/ChristmasTree.md index 24443ba..24fd308 100644 --- a/src/Koogle.Application/Games/ChristmasTree/ChristmasTree.md +++ b/src/Koogle.Application/Games/ChristmasTree/ChristmasTree.md @@ -28,30 +28,30 @@ Zur Vorbereitung werden zuerst zwei Kegelmannschaften gew Tannenbaum fr Anfnger Tannenbaum fr Fortgeschrittene Groe Pyramide -1 -2 2 -3 3 3 -4 4 4 4 + 1 + 2 2 + 3 3 3 + 4 4 4 4 5 5 5 5 5 -6 6 -7 7 + 6 6 + 7 7 -1 -2 2 -3 3 3 -4 4 4 4 + 1 + 2 2 + 3 3 3 + 4 4 4 4 5 5 5 5 5 -6 6 6 6 -7 7 7 -8 8 -9 + 6 6 6 6 + 7 7 7 + 8 8 + 9 -9 -8 8 -7 7 7 -6 6 6 6 -5 5 5 5 5 -4 4 4 4 4 4 + 9 + 8 8 + 7 7 7 + 6 6 6 6 + 5 5 5 5 5 + 4 4 4 4 4 4 1 2 3 3 3 2 1 Spielen diff --git a/src/Koogle.Application/Games/ChristmasTree/ChristmasTreeGameDefinition.cs b/src/Koogle.Application/Games/ChristmasTree/ChristmasTreeGameDefinition.cs new file mode 100644 index 0000000..6d80739 --- /dev/null +++ b/src/Koogle.Application/Games/ChristmasTree/ChristmasTreeGameDefinition.cs @@ -0,0 +1,39 @@ +using Koogle.Domain.Enums; +using Koogle.Domain.Interfaces; + +namespace Koogle.Application.Games.ChristmasTree; + +/// +/// Game definition for the Christmas Tree (Tannenbaum) bowling game. +/// +public class ChristmasTreeGameDefinition : IGameDefinition +{ + /// + public string Name => "ChristmasTree"; + + /// + public string DisplayName => "Tannenbaum"; + + /// + public Type SetupComponentType => + Type.GetType("Koogle.Web.Components.Game.ChristmasTree.ChristmasTreeSetup, Koogle.Web", true)!; + + /// + public Type BoardComponentType => + Type.GetType("Koogle.Web.Components.Game.ChristmasTree.ChristmasTreeBoard, Koogle.Web", true)!; + + /// + public Type GameLogicServiceType => typeof(ChristmasTreeGameLogicService); + + /// + public Type GameModelType => typeof(ChristmasTreeGameModel); + + /// + public TeamMode TeamMode => TeamMode.Required; + + /// + public int MinTeams => 2; + + /// + public int? MaxTeams => null; // Unlimited teams +} diff --git a/src/Koogle.Application/Games/ChristmasTree/ChristmasTreeGameLogicService.cs b/src/Koogle.Application/Games/ChristmasTree/ChristmasTreeGameLogicService.cs new file mode 100644 index 0000000..6a30f34 --- /dev/null +++ b/src/Koogle.Application/Games/ChristmasTree/ChristmasTreeGameLogicService.cs @@ -0,0 +1,766 @@ +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 + } + ]; + } + } + } + + 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 +} diff --git a/src/Koogle.Application/Games/ChristmasTree/ChristmasTreeGameModel.cs b/src/Koogle.Application/Games/ChristmasTree/ChristmasTreeGameModel.cs new file mode 100644 index 0000000..a96cb94 --- /dev/null +++ b/src/Koogle.Application/Games/ChristmasTree/ChristmasTreeGameModel.cs @@ -0,0 +1,161 @@ +namespace Koogle.Application.Games.ChristmasTree; + +/// +/// Game state model for the Christmas Tree (Tannenbaum) bowling game. +/// +public record ChristmasTreeGameModel : IGameModel +{ + /// + /// The tree variant being played. + /// + public TreeVariant Variant { get; init; } + + /// + /// Tree state for each team. Key = team index (0-based). + /// + public Dictionary TeamTrees { get; init; } = new(); + + /// + /// Team information from setup (names and player IDs). + /// + public IReadOnlyList Teams { get; init; } = []; + + /// + /// Current team index (0-based). + /// + public int CurrentTeamIndex { get; set; } + + /// + /// Current player index within the current team. + /// + public int CurrentPlayerIndexInTeam { get; set; } + + /// + /// Whether continue-on-miss variant is enabled. + /// + public bool EnableContinueOnMiss { get; init; } + + /// + /// Maximum throws when continuing on miss. + /// + public int MaxContinueThrows { get; init; } + + /// + /// Remaining throws in continue mode. + /// + public int ContinueThrowsRemaining { get; set; } + + /// + /// Whether player is currently in continue mode (must keep throwing). + /// + public bool IsInContinueMode { get; set; } + + /// + /// When continue mode fails, opponent team index that can select a number. + /// Null when not in selection phase. + /// + public int? OpponentSelectingTeamIndex { get; set; } + + /// + public Guid? WinnerId { get; set; } + + /// + /// Winning team index (more meaningful than WinnerId for team games). + /// + public int? WinnerTeamIndex { get; set; } + + /// + public bool IsGameOver { get; set; } + + /// + /// Information about the last throw for UI display. + /// + public ChristmasTreeLastThrow? LastThrow { get; set; } +} + +/// +/// State of a team's tree. +/// +public record TeamTreeState +{ + /// + /// Remaining count for each number. Key = number (1-9), Value = remaining count. + /// + public Dictionary RemainingNumbers { get; init; } = new(); + + /// + /// Total numbers crossed out by this team. + /// + public int CrossedOutCount { get; set; } + + /// + /// Total throws by this team. + /// + public int ThrowCount { get; set; } + + /// + /// Total pins knocked down by this team. + /// + public int TotalPins { get; set; } +} + +/// +/// Information about the last throw for UI display. +/// +public record ChristmasTreeLastThrow +{ + /// + /// Player who threw. + /// + public Guid PlayerId { get; init; } + + /// + /// Team index of the player. + /// + public int TeamIndex { get; init; } + + /// + /// Pins knocked down. + /// + public int PinsKnocked { get; init; } + + /// + /// True if throw was a gutter (0 pins, ball in gutter). + /// + public bool WasGutter { get; init; } + + /// + /// True if number was crossed from own tree. + /// + public bool CrossedOwnTree { get; init; } + + /// + /// True if number was crossed from opponent trees. + /// + public bool CrossedOpponentTrees { get; init; } + + /// + /// Team indices where number was crossed (when crossing opponents). + /// + public int[]? OpponentTeamsCrossed { get; init; } + + /// + /// True if number couldn't be crossed anywhere (miss). + /// + public bool WasMiss { get; init; } + + /// + /// True if this throw triggered game end. + /// + public bool TriggeredGameEnd { get; init; } + + /// + /// True if player is still in continue mode after this throw. + /// + public bool StillInContinueMode { get; init; } + + /// + /// Throws remaining in continue mode. + /// + public int ContinueThrowsRemaining { get; init; } +} diff --git a/src/Koogle.Application/Games/ChristmasTree/ChristmasTreeGameSetup.cs b/src/Koogle.Application/Games/ChristmasTree/ChristmasTreeGameSetup.cs new file mode 100644 index 0000000..a42afb7 --- /dev/null +++ b/src/Koogle.Application/Games/ChristmasTree/ChristmasTreeGameSetup.cs @@ -0,0 +1,96 @@ +using Koogle.Domain.Enums; + +namespace Koogle.Application.Games.ChristmasTree; + +/// +/// Tree variant for the Christmas Tree game. +/// +public enum TreeVariant +{ + /// + /// Beginner variant: 1×1, 2×2, 3×3, 4×4, 5×5, 6×2, 7×2 (19 total) + /// + Beginner, + + /// + /// Advanced variant (Pyramid): 1×1, 2×2, 3×3, 4×4, 5×5, 6×4, 7×3, 8×2, 9×1 (25 total) + /// + Advanced, + + /// + /// Large pyramid: 1×2, 2×3, 3×5, 4×6, 5×5, 6×4, 7×3, 8×2, 9×1 (31 total) + /// + LargePyramid +} + +/// +/// Setup configuration for the Christmas Tree (Tannenbaum) bowling game. +/// +public record ChristmasTreeGameSetup : GameSetupModelBase +{ + /// + public override string GameType => "ChristmasTree"; + + /// + /// The tree variant to play. + /// + public TreeVariant Variant { get; init; } = TreeVariant.Beginner; + + /// + /// When enabled, player must continue throwing (up to MaxContinueThrows) + /// if their number cannot be crossed out anywhere. + /// + public bool EnableContinueOnMiss { get; init; } = false; + + /// + /// Maximum throws allowed when continuing on miss (default: 3). + /// + public int MaxContinueThrows { get; init; } = 3; + + /// + /// Creates a new setup with default values. + /// + public static ChristmasTreeGameSetup Create( + ThrowMode throwMode = ThrowMode.Reposition, + int throwsPerRound = 1, + TreeVariant variant = TreeVariant.Beginner, + bool enableContinueOnMiss = false, + int maxContinueThrows = 3, + IReadOnlyList? teams = null) => new() + { + ThrowMode = throwMode, + ThrowsPerRound = throwsPerRound, + ParticipantsMode = ParticipantsMode.GameLogic, + Variant = variant, + EnableContinueOnMiss = enableContinueOnMiss, + MaxContinueThrows = maxContinueThrows, + Teams = teams + }; + + /// + /// Returns the tree structure for a given variant. + /// Key = pin number (1-9), Value = count of that number in tree. + /// + public static Dictionary GetTreeStructure(TreeVariant variant) => variant switch + { + TreeVariant.Beginner => new Dictionary + { + [1] = 1, [2] = 2, [3] = 3, [4] = 4, [5] = 5, [6] = 2, [7] = 2 + }, + TreeVariant.Advanced => new Dictionary + { + [1] = 1, [2] = 2, [3] = 3, [4] = 4, [5] = 5, [6] = 4, [7] = 3, [8] = 2, [9] = 1 + }, + TreeVariant.LargePyramid => new Dictionary + { + [1] = 2, [2] = 3, [3] = 5, [4] = 6, [5] = 5, [6] = 4, [7] = 3, [8] = 2, [9] = 1 + }, + _ => throw new ArgumentOutOfRangeException(nameof(variant)) + }; + + /// + /// Returns the total number of fields in the tree for a given variant. + /// + public static int GetTotalFields(TreeVariant variant) => + GetTreeStructure(variant).Values.Sum(); +} diff --git a/src/Koogle.Application/Games/IGameSetupModel.cs b/src/Koogle.Application/Games/IGameSetupModel.cs index e2750e7..b72b0ec 100644 --- a/src/Koogle.Application/Games/IGameSetupModel.cs +++ b/src/Koogle.Application/Games/IGameSetupModel.cs @@ -12,6 +12,7 @@ namespace Koogle.Application.Games; [JsonDerivedType(typeof(Training.TrainingGameSetup), "Training")] [JsonDerivedType(typeof(DeathBox.DeathBoxGameSetup), "DeathBox")] [JsonDerivedType(typeof(FoxHunt.FoxHuntGameSetup), "FoxHunt")] +[JsonDerivedType(typeof(ChristmasTree.ChristmasTreeGameSetup), "ChristmasTree")] public interface IGameSetupModel { /// diff --git a/src/Koogle.Web/Components/Game/ChristmasTree/ChristmasTreeBoard.razor b/src/Koogle.Web/Components/Game/ChristmasTree/ChristmasTreeBoard.razor new file mode 100644 index 0000000..fc67e8d --- /dev/null +++ b/src/Koogle.Web/Components/Game/ChristmasTree/ChristmasTreeBoard.razor @@ -0,0 +1,296 @@ +@using Fluxor +@using Koogle.Application.DTOs +@using Koogle.Application.Games +@using Koogle.Application.Games.ChristmasTree +@using Koogle.Web.Store.GameState +@using Koogle.Web.Store.DayState +@using MudBlazor + +@inherits Fluxor.Blazor.Web.Components.FluxorComponent + +@implements IDisposable +@inject IState GameState +@inject IState DayState + + + @if (_model == null) + { + + Spiel noch nicht gestartet. + + } + else + { + @* Game info header *@ + + + + Variante: @GetVariantName(_model.Variant) + + @if (_model.EnableContinueOnMiss && _model.IsInContinueMode) + { + + Weiterkegeln: @_model.ContinueThrowsRemaining Würfe übrig + + } + + + + @* Last throw info *@ + @if (_model.LastThrow != null) + { + + @GetLastThrowMessage() + + } + + @* Winner announcement *@ + @if (_model.IsGameOver && _model.WinnerTeamIndex.HasValue) + { + var winnerTeam = _model.Teams[_model.WinnerTeamIndex.Value]; + + + + @winnerTeam.Name hat gewonnen! + + + } + + @* Teams grid *@ + + @for (int i = 0; i < _model.Teams.Count; i++) + { + var teamIndex = i; + var team = _model.Teams[teamIndex]; + var treeState = _model.TeamTrees.GetValueOrDefault(teamIndex, new TeamTreeState()); + var isCurrentTeam = teamIndex == _model.CurrentTeamIndex && !_model.IsGameOver; + var isWinner = _model.WinnerTeamIndex == teamIndex; + var isLoser = _model.IsGameOver && _model.WinnerTeamIndex != teamIndex; + + + + + + + @if (isWinner) + { + + } + else if (isCurrentTeam) + { + + } + @team.Name + + + + + @treeState.CrossedOutCount gestrichen + + + + + @* Tree visualization *@ + + @foreach (var (num, totalCount) in ChristmasTreeGameSetup.GetTreeStructure(_model.Variant).OrderBy(x => x.Key)) + { + var remaining = treeState.RemainingNumbers.GetValueOrDefault(num, 0); + var crossed = totalCount - remaining; + + + @for (int j = 0; j < totalCount; j++) + { + var isCrossed = j < crossed; + + @num + + } + + } + + + @* Team players *@ + + + Spieler: @string.Join(", ", team.PlayerIds.Select(GetPlayerName)) + + + @* Stats *@ + @if (treeState.ThrowCount > 0) + { + + Würfe: @treeState.ThrowCount | Kegel: @treeState.TotalPins + + } + + @* Penalty for losers *@ + @if (isLoser) + { + var remaining = treeState.RemainingNumbers.Values.Sum(); + + Strafe: @remaining pro Spieler + + } + + + + } + + + @* Current player info *@ + @if (!_model.IsGameOver) + { + var currentTeam = _model.Teams[_model.CurrentTeamIndex]; + var currentPlayerIndex = _model.CurrentPlayerIndexInTeam % Math.Max(1, currentTeam.PlayerIds.Count); + var currentPlayerId = currentTeam.PlayerIds.Count > currentPlayerIndex + ? currentTeam.PlayerIds[currentPlayerIndex] + : currentTeam.PlayerIds.FirstOrDefault(); + var currentPlayerName = GetPlayerName(currentPlayerId); + + + + + + @currentPlayerName (@currentTeam.Name) ist am Zug + + + + } + + @* Opponent selection mode *@ + @if (_model.OpponentSelectingTeamIndex.HasValue) + { + var selectingTeam = _model.Teams[_model.OpponentSelectingTeamIndex.Value]; + + @selectingTeam.Name darf eine Zahl zum Streichen auswählen! + + } + } + + +@code { + private ChristmasTreeGameModel? _model; + + protected override void OnInitialized() + { + base.OnInitialized(); + GameState.StateChanged += OnGameStateChanged; + UpdateModel(); + } + + private void OnGameStateChanged(object? sender, EventArgs e) + { + UpdateModel(); + InvokeAsync(StateHasChanged); + } + + private void UpdateModel() + { + _model = null; + + var gameState = GameState.Value; + if (gameState.GameModel is ChristmasTreeGameModel model) + { + _model = model; + } + else if (gameState.GameModel is System.Text.Json.JsonElement jsonElement) + { + try + { + _model = System.Text.Json.JsonSerializer.Deserialize( + jsonElement.GetRawText(), + GameModelFactory.JsonSerializerOptions); + } + catch + { + return; + } + } + } + + private string GetPlayerName(Guid playerId) + { + var person = DayState.Value.AvailablePersons.FirstOrDefault(p => p.Id == playerId); + return person?.Name ?? "Unbekannt"; + } + + private string GetVariantName(TreeVariant variant) => variant switch + { + TreeVariant.Beginner => "Anfänger (1-7)", + TreeVariant.Advanced => "Fortgeschritten (1-9)", + TreeVariant.LargePyramid => "Große Pyramide", + _ => "Unbekannt" + }; + + private Color GetNumberColor(int number) => number switch + { + 1 or 9 => Color.Error, + 2 or 8 => Color.Warning, + 3 or 7 => Color.Info, + 4 or 6 => Color.Primary, + 5 => Color.Success, + _ => Color.Default + }; + + private Severity GetLastThrowSeverity() + { + if (_model?.LastThrow == null) return Severity.Info; + + if (_model.LastThrow.WasGutter || _model.LastThrow.WasMiss) + return Severity.Warning; + + if (_model.LastThrow.CrossedOwnTree) + return Severity.Success; + + if (_model.LastThrow.CrossedOpponentTrees) + return Severity.Info; + + return Severity.Normal; + } + + private string GetLastThrowMessage() + { + if (_model?.LastThrow == null) return ""; + + var lt = _model.LastThrow; + var playerName = GetPlayerName(lt.PlayerId); + var teamName = _model.Teams.Count > lt.TeamIndex ? _model.Teams[lt.TeamIndex].Name : "?"; + + if (lt.WasGutter) + return $"{playerName} ({teamName}): Pudel!"; + + if (lt.WasMiss && lt.PinsKnocked == 0) + return $"{playerName} ({teamName}): {lt.PinsKnocked} Kegel - Fehlwurf"; + + if (lt.CrossedOwnTree) + return $"{playerName} ({teamName}): {lt.PinsKnocked} - eigenen Baum gestrichen!"; + + if (lt.CrossedOpponentTrees && lt.OpponentTeamsCrossed != null) + { + var opponentNames = lt.OpponentTeamsCrossed + .Where(i => i < _model.Teams.Count) + .Select(i => _model.Teams[i].Name); + return $"{playerName} ({teamName}): {lt.PinsKnocked} - bei {string.Join(", ", opponentNames)} gestrichen!"; + } + + if (lt.WasMiss) + { + if (lt.StillInContinueMode) + return $"{playerName} ({teamName}): {lt.PinsKnocked} - nicht streichbar, noch {lt.ContinueThrowsRemaining} Würfe"; + return $"{playerName} ({teamName}): {lt.PinsKnocked} - nicht streichbar"; + } + + return $"{playerName} ({teamName}): {lt.PinsKnocked} Kegel"; + } + + public void Dispose() + { + GameState.StateChanged -= OnGameStateChanged; + } +} diff --git a/src/Koogle.Web/Components/Game/ChristmasTree/ChristmasTreeSetup.razor b/src/Koogle.Web/Components/Game/ChristmasTree/ChristmasTreeSetup.razor new file mode 100644 index 0000000..f6f9b77 --- /dev/null +++ b/src/Koogle.Web/Components/Game/ChristmasTree/ChristmasTreeSetup.razor @@ -0,0 +1,148 @@ +@using Koogle.Application.Games +@using Koogle.Application.Games.ChristmasTree +@using Koogle.Application.Interfaces +@using Koogle.Domain.Enums +@using MudBlazor + +@inject ITriggerService TriggerService +@implements IGameSetupControl + + + Tannenbaum Einstellungen + + + + Anfänger (1-7) + Fortgeschritten (1-9) + Große Pyramide + + + + + @if (_options.EnableContinueOnMiss) + { + + } + + + @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: +
    +
  • Jedes Team hat einen Tannenbaum mit Zahlen
  • +
  • Geworfene Holzzahl wird zuerst beim eigenen Baum gestrichen
  • +
  • Falls nicht vorhanden, wird sie bei ALLEN Gegnern gestrichen
  • +
  • Spiel endet, wenn die letzte 5 gestrichen ist
  • +
  • Team mit den meisten gestrichenen Zahlen gewinnt
  • +
  • Verlierer zahlen Strafe pro verbleibender Zahl
  • +
+
+ + @* Preview tree structure *@ + + + @foreach (var (num, count) in ChristmasTreeGameSetup.GetTreeStructure(_options.Variant).OrderBy(x => x.Key)) + { + + @num: @(new string('●', count)) + + } + + Gesamt: @ChristmasTreeGameSetup.GetTotalFields(_options.Variant) Felder + + + +
+ +@code { + [Parameter] + public EventCallback OnOptionsChanged { get; set; } + + [Parameter] + public object? InitialOptions { get; set; } + + [Parameter] + public IReadOnlyList? Teams { get; set; } + + private ChristmasTreeSetupOptions _options = new(); + private bool _hasExpensePointTrigger = true; + + protected override async Task OnInitializedAsync() + { + if (InitialOptions is ChristmasTreeGameSetup setup) + { + _options = new ChristmasTreeSetupOptions + { + Variant = setup.Variant, + EnableContinueOnMiss = setup.EnableContinueOnMiss, + MaxContinueThrows = setup.MaxContinueThrows + }; + } + + _hasExpensePointTrigger = await TriggerService.HasExpensesForTriggerAsync(ExpenseTriggerType.ExpensePoint); + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + await NotifyOptionsChanged(); + } + } + + private async Task NotifyOptionsChanged() + { + await OnOptionsChanged.InvokeAsync(GameSetupModel); + } + + private string GetVariantDescription(TreeVariant variant) => variant switch + { + TreeVariant.Beginner => $"Zahlen 1-7, {ChristmasTreeGameSetup.GetTotalFields(variant)} Felder", + TreeVariant.Advanced => $"Zahlen 1-9, {ChristmasTreeGameSetup.GetTotalFields(variant)} Felder (Pyramide)", + TreeVariant.LargePyramid => $"Zahlen 1-9, {ChristmasTreeGameSetup.GetTotalFields(variant)} Felder (Große Pyramide)", + _ => "" + }; + + public IGameSetupModel GameSetupModel => new ChristmasTreeGameSetup + { + ThrowMode = ThrowMode.Reposition, + ThrowsPerRound = 1, + ParticipantsMode = ParticipantsMode.GameLogic, + Variant = _options.Variant, + EnableContinueOnMiss = _options.EnableContinueOnMiss, + MaxContinueThrows = _options.MaxContinueThrows, + Teams = Teams + }; + + private class ChristmasTreeSetupOptions + { + public TreeVariant Variant { get; set; } = TreeVariant.Beginner; + public bool EnableContinueOnMiss { get; set; } = false; + public int MaxContinueThrows { get; set; } = 3; + } +} diff --git a/src/Koogle.Web/Components/Game/GameSetupDialog.razor b/src/Koogle.Web/Components/Game/GameSetupDialog.razor index 1b3f478..62cc0ec 100644 --- a/src/Koogle.Web/Components/Game/GameSetupDialog.razor +++ b/src/Koogle.Web/Components/Game/GameSetupDialog.razor @@ -188,11 +188,19 @@ private Dictionary GetSetupParameters() { - return new Dictionary + var parameters = new Dictionary { ["OnOptionsChanged"] = EventCallback.Factory.Create(this, OnSetupOptionsChanged), // ["InitialOptions"] = _gameSpecificSetupOptions! }; + + // Pass teams to setup component for team-based games + if (_selectedDefinition?.TeamMode != TeamMode.None && _teams.Count > 0) + { + parameters["Teams"] = _teams; + } + + return parameters; } private void OnSetupOptionsChanged(object options) diff --git a/src/Koogle.Web/Program.cs b/src/Koogle.Web/Program.cs index 7334f1e..9974416 100644 --- a/src/Koogle.Web/Program.cs +++ b/src/Koogle.Web/Program.cs @@ -19,6 +19,7 @@ using Microsoft.AspNetCore.Components.Server; using MudBlazor.Services; using System.Reflection; using Koogle.Application.Games.FoxHunt; +using Koogle.Application.Games.ChristmasTree; var builder = WebApplication.CreateBuilder(args); @@ -34,6 +35,7 @@ builder.Services.AddGameType() builder.Services.AddGameType(); builder.Services.AddGameType(); builder.Services.AddGameType(); +builder.Services.AddGameType(); // SignalR for real-time game updates builder.Services.AddSignalR();