added game christmas tree

This commit is contained in:
beo3000 2026-01-08 16:23:00 +01:00
parent 1d7139fc1a
commit 31bbaaf70a
10 changed files with 1538 additions and 21 deletions

View File

@ -28,30 +28,30 @@ Zur Vorbereitung werden zuerst zwei Kegelmannschaften gew
Tannenbaum
für Anfänger Tannenbaum
für Fortgeschrittene Große 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

View File

@ -0,0 +1,39 @@
using Koogle.Domain.Enums;
using Koogle.Domain.Interfaces;
namespace Koogle.Application.Games.ChristmasTree;
/// <summary>
/// Game definition for the Christmas Tree (Tannenbaum) bowling game.
/// </summary>
public class ChristmasTreeGameDefinition : IGameDefinition
{
/// <inheritdoc />
public string Name => "ChristmasTree";
/// <inheritdoc />
public string DisplayName => "Tannenbaum";
/// <inheritdoc />
public Type SetupComponentType =>
Type.GetType("Koogle.Web.Components.Game.ChristmasTree.ChristmasTreeSetup, Koogle.Web", true)!;
/// <inheritdoc />
public Type BoardComponentType =>
Type.GetType("Koogle.Web.Components.Game.ChristmasTree.ChristmasTreeBoard, Koogle.Web", true)!;
/// <inheritdoc />
public Type GameLogicServiceType => typeof(ChristmasTreeGameLogicService);
/// <inheritdoc />
public Type GameModelType => typeof(ChristmasTreeGameModel);
/// <inheritdoc />
public TeamMode TeamMode => TeamMode.Required;
/// <inheritdoc />
public int MinTeams => 2;
/// <inheritdoc />
public int? MaxTeams => null; // Unlimited teams
}

View File

@ -0,0 +1,766 @@
using System.Text.Json;
using Koogle.Domain.Enums;
namespace Koogle.Application.Games.ChristmasTree;
/// <summary>
/// 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.
/// </summary>
public class ChristmasTreeGameLogicService : IGameLogicService
{
/// <inheritdoc />
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<int, TeamTreeState>();
for (int i = 0; i < options.Teams.Count; i++)
{
teamTrees[i] = new TeamTreeState
{
RemainingNumbers = new Dictionary<int, int>(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
};
}
/// <inheritdoc />
public (object UpdatedModel, ThrowResult Result) ProcessThrow(object gameModel, AfterThrowState afterThrow)
{
var model = this.CastModel<ChristmasTreeGameModel>(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<int, TeamTreeState>(model.TeamTrees);
var currentTeamState = teamTrees[playerTeamIndex] with
{
ThrowCount = teamTrees[playerTeamIndex].ThrowCount + 1,
TotalPins = teamTrees[playerTeamIndex].TotalPins + pinsKnocked
};
teamTrees[playerTeamIndex] = currentTeamState;
var triggers = new List<TriggerEvent>();
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<int, TeamTreeState> 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<int, TeamTreeState> teamTrees,
ChristmasTreeLastThrow lastThrow,
List<TriggerEvent> 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<TriggerEvent> 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
}
});
}
/// <inheritdoc />
public (bool IsGameOver, Guid? WinnerId) CheckGameEnd(object gameModel)
{
var model = this.CastModel<ChristmasTreeGameModel>(gameModel);
return (model.IsGameOver, model.WinnerId);
}
/// <inheritdoc />
public IReadOnlyDictionary<Guid, PlayerStatsSummary> GetPlayerStats(object gameModel)
{
var model = this.CastModel<ChristmasTreeGameModel>(gameModel);
var stats = new Dictionary<Guid, PlayerStatsSummary>();
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<string, object>
{
["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;
}
/// <inheritdoc />
public IReadOnlyList<GameActionDescriptor> GetAvailableActions(object gameModel, Guid currentPlayerId)
{
var model = this.CastModel<ChristmasTreeGameModel>(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 [];
}
/// <inheritdoc />
public GameActionResult ExecuteAction(
object gameModel,
string actionId,
Guid currentPlayerId,
IReadOnlyDictionary<string, object>? parameters = null)
{
return actionId switch
{
"selectNumber" => ExecuteSelectNumber(gameModel, currentPlayerId, parameters),
_ => GameActionResult.Failure($"Unknown action: {actionId}")
};
}
private GameActionResult ExecuteSelectNumber(
object gameModel,
Guid currentPlayerId,
IReadOnlyDictionary<string, object>? parameters)
{
var model = this.CastModel<ChristmasTreeGameModel>(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<int, TeamTreeState>(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<int, int>(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<TriggerEvent>();
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);
}
/// <inheritdoc />
public GameSetupValidationResult ValidateSetup(object? setupOptions)
{
if (setupOptions is null)
{
return GameSetupValidationResult.Invalid(["Setup options required."]);
}
var options = ParseSetupOptions(setupOptions);
var errors = new List<string>();
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<int, TeamTreeState> teamTrees, int teamIndex, int number)
{
var state = teamTrees[teamIndex];
if (state.RemainingNumbers.TryGetValue(number, out var remaining) && remaining > 0)
{
var updatedNumbers = new Dictionary<int, int>(state.RemainingNumbers)
{
[number] = remaining - 1
};
teamTrees[teamIndex] = state with { RemainingNumbers = updatedNumbers };
return true;
}
return false;
}
private static List<int> TryCrossFromOpponents(
Dictionary<int, TeamTreeState> teamTrees,
int playerTeamIndex,
int number)
{
var crossedTeams = new List<int>();
foreach (var (teamIndex, state) in teamTrees)
{
if (teamIndex == playerTeamIndex) continue;
if (state.RemainingNumbers.TryGetValue(number, out var remaining) && remaining > 0)
{
var updatedNumbers = new Dictionary<int, int>(state.RemainingNumbers)
{
[number] = remaining - 1
};
teamTrees[teamIndex] = state with { RemainingNumbers = updatedNumbers };
crossedTeams.Add(teamIndex);
}
}
return crossedTeams;
}
private static bool CheckAllFivesGone(Dictionary<int, TeamTreeState> 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<ChristmasTreeGameSetup>(
jsonElement.GetRawText(),
GameModelFactory.JsonSerializerOptions) ?? new ChristmasTreeGameSetup();
}
catch
{
return new ChristmasTreeGameSetup();
}
}
return new ChristmasTreeGameSetup();
}
#endregion
}

View File

@ -0,0 +1,161 @@
namespace Koogle.Application.Games.ChristmasTree;
/// <summary>
/// Game state model for the Christmas Tree (Tannenbaum) bowling game.
/// </summary>
public record ChristmasTreeGameModel : IGameModel
{
/// <summary>
/// The tree variant being played.
/// </summary>
public TreeVariant Variant { get; init; }
/// <summary>
/// Tree state for each team. Key = team index (0-based).
/// </summary>
public Dictionary<int, TeamTreeState> TeamTrees { get; init; } = new();
/// <summary>
/// Team information from setup (names and player IDs).
/// </summary>
public IReadOnlyList<GameTeam> Teams { get; init; } = [];
/// <summary>
/// Current team index (0-based).
/// </summary>
public int CurrentTeamIndex { get; set; }
/// <summary>
/// Current player index within the current team.
/// </summary>
public int CurrentPlayerIndexInTeam { get; set; }
/// <summary>
/// Whether continue-on-miss variant is enabled.
/// </summary>
public bool EnableContinueOnMiss { get; init; }
/// <summary>
/// Maximum throws when continuing on miss.
/// </summary>
public int MaxContinueThrows { get; init; }
/// <summary>
/// Remaining throws in continue mode.
/// </summary>
public int ContinueThrowsRemaining { get; set; }
/// <summary>
/// Whether player is currently in continue mode (must keep throwing).
/// </summary>
public bool IsInContinueMode { get; set; }
/// <summary>
/// When continue mode fails, opponent team index that can select a number.
/// Null when not in selection phase.
/// </summary>
public int? OpponentSelectingTeamIndex { get; set; }
/// <inheritdoc />
public Guid? WinnerId { get; set; }
/// <summary>
/// Winning team index (more meaningful than WinnerId for team games).
/// </summary>
public int? WinnerTeamIndex { get; set; }
/// <inheritdoc />
public bool IsGameOver { get; set; }
/// <summary>
/// Information about the last throw for UI display.
/// </summary>
public ChristmasTreeLastThrow? LastThrow { get; set; }
}
/// <summary>
/// State of a team's tree.
/// </summary>
public record TeamTreeState
{
/// <summary>
/// Remaining count for each number. Key = number (1-9), Value = remaining count.
/// </summary>
public Dictionary<int, int> RemainingNumbers { get; init; } = new();
/// <summary>
/// Total numbers crossed out by this team.
/// </summary>
public int CrossedOutCount { get; set; }
/// <summary>
/// Total throws by this team.
/// </summary>
public int ThrowCount { get; set; }
/// <summary>
/// Total pins knocked down by this team.
/// </summary>
public int TotalPins { get; set; }
}
/// <summary>
/// Information about the last throw for UI display.
/// </summary>
public record ChristmasTreeLastThrow
{
/// <summary>
/// Player who threw.
/// </summary>
public Guid PlayerId { get; init; }
/// <summary>
/// Team index of the player.
/// </summary>
public int TeamIndex { get; init; }
/// <summary>
/// Pins knocked down.
/// </summary>
public int PinsKnocked { get; init; }
/// <summary>
/// True if throw was a gutter (0 pins, ball in gutter).
/// </summary>
public bool WasGutter { get; init; }
/// <summary>
/// True if number was crossed from own tree.
/// </summary>
public bool CrossedOwnTree { get; init; }
/// <summary>
/// True if number was crossed from opponent trees.
/// </summary>
public bool CrossedOpponentTrees { get; init; }
/// <summary>
/// Team indices where number was crossed (when crossing opponents).
/// </summary>
public int[]? OpponentTeamsCrossed { get; init; }
/// <summary>
/// True if number couldn't be crossed anywhere (miss).
/// </summary>
public bool WasMiss { get; init; }
/// <summary>
/// True if this throw triggered game end.
/// </summary>
public bool TriggeredGameEnd { get; init; }
/// <summary>
/// True if player is still in continue mode after this throw.
/// </summary>
public bool StillInContinueMode { get; init; }
/// <summary>
/// Throws remaining in continue mode.
/// </summary>
public int ContinueThrowsRemaining { get; init; }
}

View File

@ -0,0 +1,96 @@
using Koogle.Domain.Enums;
namespace Koogle.Application.Games.ChristmasTree;
/// <summary>
/// Tree variant for the Christmas Tree game.
/// </summary>
public enum TreeVariant
{
/// <summary>
/// Beginner variant: 1×1, 2×2, 3×3, 4×4, 5×5, 6×2, 7×2 (19 total)
/// </summary>
Beginner,
/// <summary>
/// Advanced variant (Pyramid): 1×1, 2×2, 3×3, 4×4, 5×5, 6×4, 7×3, 8×2, 9×1 (25 total)
/// </summary>
Advanced,
/// <summary>
/// Large pyramid: 1×2, 2×3, 3×5, 4×6, 5×5, 6×4, 7×3, 8×2, 9×1 (31 total)
/// </summary>
LargePyramid
}
/// <summary>
/// Setup configuration for the Christmas Tree (Tannenbaum) bowling game.
/// </summary>
public record ChristmasTreeGameSetup : GameSetupModelBase
{
/// <inheritdoc />
public override string GameType => "ChristmasTree";
/// <summary>
/// The tree variant to play.
/// </summary>
public TreeVariant Variant { get; init; } = TreeVariant.Beginner;
/// <summary>
/// When enabled, player must continue throwing (up to MaxContinueThrows)
/// if their number cannot be crossed out anywhere.
/// </summary>
public bool EnableContinueOnMiss { get; init; } = false;
/// <summary>
/// Maximum throws allowed when continuing on miss (default: 3).
/// </summary>
public int MaxContinueThrows { get; init; } = 3;
/// <summary>
/// Creates a new setup with default values.
/// </summary>
public static ChristmasTreeGameSetup Create(
ThrowMode throwMode = ThrowMode.Reposition,
int throwsPerRound = 1,
TreeVariant variant = TreeVariant.Beginner,
bool enableContinueOnMiss = false,
int maxContinueThrows = 3,
IReadOnlyList<GameTeam>? teams = null) => new()
{
ThrowMode = throwMode,
ThrowsPerRound = throwsPerRound,
ParticipantsMode = ParticipantsMode.GameLogic,
Variant = variant,
EnableContinueOnMiss = enableContinueOnMiss,
MaxContinueThrows = maxContinueThrows,
Teams = teams
};
/// <summary>
/// Returns the tree structure for a given variant.
/// Key = pin number (1-9), Value = count of that number in tree.
/// </summary>
public static Dictionary<int, int> GetTreeStructure(TreeVariant variant) => variant switch
{
TreeVariant.Beginner => new Dictionary<int, int>
{
[1] = 1, [2] = 2, [3] = 3, [4] = 4, [5] = 5, [6] = 2, [7] = 2
},
TreeVariant.Advanced => new Dictionary<int, int>
{
[1] = 1, [2] = 2, [3] = 3, [4] = 4, [5] = 5, [6] = 4, [7] = 3, [8] = 2, [9] = 1
},
TreeVariant.LargePyramid => new Dictionary<int, int>
{
[1] = 2, [2] = 3, [3] = 5, [4] = 6, [5] = 5, [6] = 4, [7] = 3, [8] = 2, [9] = 1
},
_ => throw new ArgumentOutOfRangeException(nameof(variant))
};
/// <summary>
/// Returns the total number of fields in the tree for a given variant.
/// </summary>
public static int GetTotalFields(TreeVariant variant) =>
GetTreeStructure(variant).Values.Sum();
}

View File

@ -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
{
/// <summary>

View File

@ -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> GameState
@inject IState<DayState> DayState
<MudPaper Class="pa-4">
@if (_model == null)
{
<MudAlert Severity="Severity.Info">
Spiel noch nicht gestartet.
</MudAlert>
}
else
{
@* Game info header *@
<MudPaper Class="pa-3 mb-4" Elevation="0" Style="background-color: var(--mud-palette-background-grey);">
<MudStack Row="true" Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center">
<MudText Typo="Typo.body1">
<strong>Variante:</strong> @GetVariantName(_model.Variant)
</MudText>
@if (_model.EnableContinueOnMiss && _model.IsInContinueMode)
{
<MudChip T="string" Size="Size.Small" Color="Color.Warning" Variant="Variant.Filled">
Weiterkegeln: @_model.ContinueThrowsRemaining Würfe übrig
</MudChip>
}
</MudStack>
</MudPaper>
@* Last throw info *@
@if (_model.LastThrow != null)
{
<MudAlert Severity="@GetLastThrowSeverity()" Class="mb-4" Dense="true">
@GetLastThrowMessage()
</MudAlert>
}
@* Winner announcement *@
@if (_model.IsGameOver && _model.WinnerTeamIndex.HasValue)
{
var winnerTeam = _model.Teams[_model.WinnerTeamIndex.Value];
<MudAlert Severity="Severity.Success" Class="mb-4">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudIcon Icon="@Icons.Material.Filled.EmojiEvents" />
<MudText Typo="Typo.h6">@winnerTeam.Name hat gewonnen!</MudText>
</MudStack>
</MudAlert>
}
@* Teams grid *@
<MudGrid Spacing="3">
@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;
<MudItem xs="12" sm="6" md="4">
<MudCard Elevation="@(isCurrentTeam ? 4 : 1)"
Style="@(isCurrentTeam ? "border: 2px solid var(--mud-palette-primary);" : isWinner ? "border: 2px solid var(--mud-palette-success);" : "")">
<MudCardHeader>
<CardHeaderContent>
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
@if (isWinner)
{
<MudIcon Icon="@Icons.Material.Filled.EmojiEvents" Color="Color.Success" />
}
else if (isCurrentTeam)
{
<MudIcon Icon="@Icons.Material.Filled.ArrowCircleDown" Color="Color.Primary" />
}
<MudText Typo="Typo.h6">@team.Name</MudText>
</MudStack>
</CardHeaderContent>
<CardHeaderActions>
<MudChip T="string" Size="Size.Small"
Color="@(isWinner ? Color.Success : isLoser ? Color.Error : Color.Default)"
Variant="Variant.Outlined">
@treeState.CrossedOutCount gestrichen
</MudChip>
</CardHeaderActions>
</MudCardHeader>
<MudCardContent>
@* Tree visualization *@
<MudStack Spacing="1" AlignItems="AlignItems.Center">
@foreach (var (num, totalCount) in ChristmasTreeGameSetup.GetTreeStructure(_model.Variant).OrderBy(x => x.Key))
{
var remaining = treeState.RemainingNumbers.GetValueOrDefault(num, 0);
var crossed = totalCount - remaining;
<MudStack Row="true" Spacing="1" Justify="Justify.Center">
@for (int j = 0; j < totalCount; j++)
{
var isCrossed = j < crossed;
<MudChip T="string" Size="Size.Small"
Style="@(isCrossed ? "text-decoration: line-through; opacity: 0.5;" : "")"
Color="@(isCrossed ? Color.Default : GetNumberColor(num))"
Variant="@(isCrossed ? Variant.Text : Variant.Filled)">
@num
</MudChip>
}
</MudStack>
}
</MudStack>
@* Team players *@
<MudDivider Class="my-3" />
<MudText Typo="Typo.caption" Color="Color.Secondary">
Spieler: @string.Join(", ", team.PlayerIds.Select(GetPlayerName))
</MudText>
@* Stats *@
@if (treeState.ThrowCount > 0)
{
<MudText Typo="Typo.caption" Color="Color.Secondary">
Würfe: @treeState.ThrowCount | Kegel: @treeState.TotalPins
</MudText>
}
@* Penalty for losers *@
@if (isLoser)
{
var remaining = treeState.RemainingNumbers.Values.Sum();
<MudAlert Severity="Severity.Error" Dense="true" Class="mt-2">
Strafe: @remaining pro Spieler
</MudAlert>
}
</MudCardContent>
</MudCard>
</MudItem>
}
</MudGrid>
@* 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);
<MudPaper Class="pa-3 mt-4" Elevation="0" Style="background-color: var(--mud-palette-background-grey);">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudIcon Icon="@Icons.Material.Filled.Person" Color="Color.Primary" />
<MudText Typo="Typo.body1">
<strong>@currentPlayerName</strong> (@currentTeam.Name) ist am Zug
</MudText>
</MudStack>
</MudPaper>
}
@* Opponent selection mode *@
@if (_model.OpponentSelectingTeamIndex.HasValue)
{
var selectingTeam = _model.Teams[_model.OpponentSelectingTeamIndex.Value];
<MudAlert Severity="Severity.Warning" Class="mt-4">
<strong>@selectingTeam.Name</strong> darf eine Zahl zum Streichen auswählen!
</MudAlert>
}
}
</MudPaper>
@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<ChristmasTreeGameModel>(
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;
}
}

View File

@ -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
<MudPaper Class="pa-4">
<MudText Typo="Typo.h6" Class="mb-4">Tannenbaum Einstellungen</MudText>
<MudStack Spacing="4">
<MudSelect T="TreeVariant"
@bind-Value="_options.Variant"
Label="Baum-Variante"
Variant="Variant.Outlined"
HelperText="@GetVariantDescription(_options.Variant)">
<MudSelectItem Value="TreeVariant.Beginner">Anfänger (1-7)</MudSelectItem>
<MudSelectItem Value="TreeVariant.Advanced">Fortgeschritten (1-9)</MudSelectItem>
<MudSelectItem Value="TreeVariant.LargePyramid">Große Pyramide</MudSelectItem>
</MudSelect>
<MudSwitch T="bool"
@bind-Value="_options.EnableContinueOnMiss"
Label="Weiterkegeln bei Fehlwurf"
Color="Color.Primary" />
@if (_options.EnableContinueOnMiss)
{
<MudNumericField T="int"
@bind-Value="_options.MaxContinueThrows"
Label="Max. Weiterwürfe"
Variant="Variant.Outlined"
Min="1"
Max="10"
HelperText="Anzahl Versuche bei nicht streichbarer Zahl (1-10)" />
}
</MudStack>
@if (!_hasExpensePointTrigger)
{
<MudAlert Severity="Severity.Warning" Class="mt-4">
<MudText Typo="Typo.body2">
<strong>Hinweis:</strong> Es ist kein "Strafpunkt"-Trigger konfiguriert.
Die Verlierer-Strafen werden nicht automatisch berechnet.
Gehe zu Stammdaten → Trigger, um einen ExpensePoint-Trigger einzurichten.
</MudText>
</MudAlert>
}
<MudDivider Class="my-4" />
<MudText Typo="Typo.body2" Color="Color.Secondary">
<strong>Spielregeln:</strong>
<ul style="margin: 8px 0 0 16px; padding: 0;">
<li>Jedes Team hat einen Tannenbaum mit Zahlen</li>
<li>Geworfene Holzzahl wird zuerst beim eigenen Baum gestrichen</li>
<li>Falls nicht vorhanden, wird sie bei ALLEN Gegnern gestrichen</li>
<li>Spiel endet, wenn die letzte 5 gestrichen ist</li>
<li>Team mit den meisten gestrichenen Zahlen gewinnt</li>
<li>Verlierer zahlen Strafe pro verbleibender Zahl</li>
</ul>
</MudText>
@* Preview tree structure *@
<MudExpansionPanel Text="Baumvorschau" Class="mt-4">
<MudStack>
@foreach (var (num, count) in ChristmasTreeGameSetup.GetTreeStructure(_options.Variant).OrderBy(x => x.Key))
{
<MudText Typo="Typo.body2">
@num: @(new string('●', count))
</MudText>
}
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="mt-2">
Gesamt: @ChristmasTreeGameSetup.GetTotalFields(_options.Variant) Felder
</MudText>
</MudStack>
</MudExpansionPanel>
</MudPaper>
@code {
[Parameter]
public EventCallback<object> OnOptionsChanged { get; set; }
[Parameter]
public object? InitialOptions { get; set; }
[Parameter]
public IReadOnlyList<GameTeam>? 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;
}
}

View File

@ -188,11 +188,19 @@
private Dictionary<string, object> GetSetupParameters()
{
return new Dictionary<string, object>
var parameters = new Dictionary<string, object>
{
["OnOptionsChanged"] = EventCallback.Factory.Create<object>(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)

View File

@ -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<TrainingGameDefinition, TrainingGameLogicService>()
builder.Services.AddGameType<ShitGameDefinition, ShitGameLogicService>();
builder.Services.AddGameType<DeathBoxGameDefinition, DeathBoxGameLogicService>();
builder.Services.AddGameType<FoxHuntGameDefinition, FoxHuntGameLogicService>();
builder.Services.AddGameType<ChristmasTreeGameDefinition, ChristmasTreeGameLogicService>();
// SignalR for real-time game updates
builder.Services.AddSignalR();