added game christmas tree
This commit is contained in:
parent
1d7139fc1a
commit
31bbaaf70a
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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; }
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in New Issue