KoogleApp/src/Koogle.Application/Games/ChristmasTree/ChristmasTreeGameLogicServi...

781 lines
26 KiB
C#

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,
Parameters =
[
new ActionParameterDefinition
{
Key = "number",
Label = "Wähle eine Zahl",
Type = "select",
Options = availableNumbers.Select(n => new ActionParameterOption
{
Value = n,
Label = n.ToString()
}).ToList()
}
]
}
];
}
}
}
return [];
}
/// <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
}