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