using FluentAssertions; using Koogle.Application.Games; using Koogle.Application.Games.DeathBox; using Koogle.Domain.Enums; using Xunit; namespace Koogle.Tests.Application.Games; /// /// Unit tests for DeathBoxGameLogicService. /// public class DeathBoxGameLogicServiceTests { private readonly DeathBoxGameLogicService _sut; public DeathBoxGameLogicServiceTests() { _sut = new DeathBoxGameLogicService(); } #region CreateInitialModel Tests [Fact] public void CreateInitialModel_CreatesPlayerStates_ForAllPlayers() { // Arrange var playerIds = new[] { Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid() }; var setup = new DeathBoxGameSetup { CoffinSize = 12 }; // Act var result = _sut.CreateInitialModel(playerIds, setup); // Assert result.Should().BeOfType(); var model = (DeathBoxGameModel)result; model.PlayerStates.Should().HaveCount(3); model.PlayerStates.Keys.Should().BeEquivalentTo(playerIds); } [Fact] public void CreateInitialModel_InitializesPlayersWithZeroMarks() { // Arrange var playerId = Guid.NewGuid(); var setup = DeathBoxGameSetup.Create(coffinSize: 10); // Act var result = _sut.CreateInitialModel([playerId], setup); // Assert var model = (DeathBoxGameModel)result; model.PlayerStates[playerId].Marks.Should().Be(0); model.PlayerStates[playerId].XCount.Should().Be(1); model.PlayerStates[playerId].EggCount.Should().Be(0); model.PlayerStates[playerId].IsEliminated.Should().BeFalse(); model.CoffinSize.Should().Be(10); } [Theory] [InlineData(6)] [InlineData(9)] [InlineData(12)] public void CreateInitialModel_SetsCoffinSize(int coffinSize) { // Arrange var setup = DeathBoxGameSetup.Create(coffinSize: coffinSize); // Act var result = _sut.CreateInitialModel([Guid.NewGuid()], setup); // Assert var model = (DeathBoxGameModel)result; model.CoffinSize.Should().Be(coffinSize); } [Fact] public void CreateInitialModel_SetsPlayerOrder() { // Arrange var playerIds = new[] { Guid.NewGuid(), Guid.NewGuid() }; var setup = DeathBoxGameSetup.Create(randomizePlayerOrder: false); // Act var result = _sut.CreateInitialModel(playerIds, setup); // Assert var model = (DeathBoxGameModel)result; model.PlayerOrder.Should().HaveCount(2); model.CurrentPlayerIndex.Should().Be(0); } [Fact] public void CreateInitialModel_StartsNotGameOver() { // Act var result = _sut.CreateInitialModel([Guid.NewGuid()], null); // Assert var model = (DeathBoxGameModel)result; model.IsGameOver.Should().BeFalse(); model.WinnerId.Should().BeNull(); model.EliminatedPlayers.Should().BeEmpty(); } #endregion #region ProcessThrow Tests - New Round (9 pins) [Fact] public void ProcessThrow_NewRound_CollectsX() { // Arrange var playerId1 = Guid.NewGuid(); var playerId2 = Guid.NewGuid(); var setup = DeathBoxGameSetup.Create(coffinSize: 12); var model = (DeathBoxGameModel)_sut.CreateInitialModel([playerId1, playerId2], setup); // New round with 9 pins knocked (>= 3) -> Board cleared var afterThrow = CreateAfterThrowState(model.PlayerOrder.First(), 9, isNewRound: true, isCleared:true); // Act var (updatedModel, _) = _sut.ProcessThrow(model, afterThrow); // Assert var updated = (DeathBoxGameModel)updatedModel; updated.PlayerStates[model.PlayerOrder.Last()].XCount.Should().Be(1); updated.LastThrow!.EarnedX.Should().BeTrue(); updated.LastThrow.WasPenalty.Should().BeFalse(); } [Fact] public void ProcessThrow_NewRound_WithLessThan3Pins_AddsMark() { // Arrange var playerId = Guid.NewGuid(); var setup = DeathBoxGameSetup.Create(coffinSize: 12); var model = (DeathBoxGameModel)_sut.CreateInitialModel([playerId], setup); // New round with 2 pins knocked (< 3) var afterThrow = CreateAfterThrowState(playerId, 2, isNewRound: true); // Act var (updatedModel, _) = _sut.ProcessThrow(model, afterThrow); // Assert var updated = (DeathBoxGameModel)updatedModel; updated.PlayerStates[playerId].XCount.Should().Be(1); updated.PlayerStates[playerId].Marks.Should().Be(1); updated.LastThrow!.WasPenalty.Should().BeTrue(); } [Fact] public void ProcessThrow_ThreeXs_ConvertToOneMark() { // Arrange var playerId1 = Guid.NewGuid(); var playerId2 = Guid.NewGuid(); var setup = DeathBoxGameSetup.Create(coffinSize: 12); var model = (DeathBoxGameModel)_sut.CreateInitialModel([playerId1, playerId2], setup); playerId1 = model.PlayerOrder.First(); playerId2 = model.PlayerOrder.Last(); // Set player to have 2 Xs already model.PlayerStates[playerId2].XCount = 2; // New round - will get 3rd X // player1 clears the pins -> player2 will have to start new round and gets a X var afterThrow = CreateAfterThrowState(playerId1, 9, isNewRound: true, isCleared:true); // Act var (updatedModel, _) = _sut.ProcessThrow(model, afterThrow); // Assert var updated = (DeathBoxGameModel)updatedModel; updated.PlayerStates[playerId2].XCount.Should().Be(0); updated.PlayerStates[playerId2].Marks.Should().Be(1); updated.LastThrow!.ConvertedXsToMark.Should().BeTrue(); } #endregion #region ProcessThrow Tests - Gutter/NoWood [Fact] public void ProcessThrow_Gutter_AddsMark() { // Arrange var playerId = Guid.NewGuid(); var setup = DeathBoxGameSetup.Create(coffinSize: 12); var model = (DeathBoxGameModel)_sut.CreateInitialModel([playerId], setup); var afterThrow = CreateAfterThrowState(playerId, 0, isGutter: true); // Act var (updatedModel, result) = _sut.ProcessThrow(model, afterThrow); // Assert var updated = (DeathBoxGameModel)updatedModel; updated.PlayerStates[playerId].Marks.Should().Be(1); updated.LastThrow!.WasGutter.Should().BeTrue(); result.Triggers.Should().ContainSingle(t => t.TriggerType == "Gutter"); } [Fact] public void ProcessThrow_NoWood_AddsMark() { // Arrange var playerId = Guid.NewGuid(); var setup = DeathBoxGameSetup.Create(coffinSize: 12); var model = (DeathBoxGameModel)_sut.CreateInitialModel([playerId], setup); var afterThrow = CreateAfterThrowState(playerId, 0, isGutter: false, isNoWood: true); // Act var (updatedModel, result) = _sut.ProcessThrow(model, afterThrow); // Assert var updated = (DeathBoxGameModel)updatedModel; updated.PlayerStates[playerId].Marks.Should().Be(1); updated.LastThrow!.WasNoWood.Should().BeTrue(); result.Triggers.Should().ContainSingle(t => t.TriggerType == "NoWood"); } [Fact] public void ProcessThrow_NewRoundWithZeroPins_OnlyOneMark() { // Arrange - new round with 0 pins should NOT double-penalize var playerId = Guid.NewGuid(); var setup = DeathBoxGameSetup.Create(coffinSize: 12); var model = (DeathBoxGameModel)_sut.CreateInitialModel([playerId], setup); var afterThrow = CreateAfterThrowState(playerId, 0, isNewRound: true, isGutter: true); // Act var (updatedModel, _) = _sut.ProcessThrow(model, afterThrow); // Assert var updated = (DeathBoxGameModel)updatedModel; // Only one mark from the <3 pins rule, not double from gutter updated.PlayerStates[playerId].Marks.Should().Be(1); } #endregion #region ProcessThrow Tests - Cleared (Eggs) [Fact] public void ProcessThrow_Cleared_CollectsEgg() { // Arrange var playerId = Guid.NewGuid(); var previousPlayer = Guid.NewGuid(); var setup = DeathBoxGameSetup.Create(coffinSize: 12); var model = (DeathBoxGameModel)_sut.CreateInitialModel([previousPlayer, playerId], setup); model = model with { PreviousPlayerId = previousPlayer }; var afterThrow = CreateAfterThrowState(playerId, 4, isCleared: true); // Act var (updatedModel, _) = _sut.ProcessThrow(model, afterThrow); // Assert var updated = (DeathBoxGameModel)updatedModel; updated.PlayerStates[playerId].EggCount.Should().Be(1); updated.LastThrow!.EarnedEgg.Should().BeTrue(); } [Fact] public void ProcessThrow_Cleared_PreviousPlayerGetsMark() { // Arrange var currentPlayer = Guid.NewGuid(); var previousPlayer = Guid.NewGuid(); var setup = DeathBoxGameSetup.Create(coffinSize: 12); var model = (DeathBoxGameModel)_sut.CreateInitialModel([previousPlayer, currentPlayer], setup); model = model with { PreviousPlayerId = previousPlayer }; var afterThrow = CreateAfterThrowState(currentPlayer, 4, isCleared: true); // Act var (updatedModel, _) = _sut.ProcessThrow(model, afterThrow); // Assert var updated = (DeathBoxGameModel)updatedModel; updated.PlayerStates[previousPlayer].Marks.Should().Be(1); updated.LastThrow!.PreviousPlayerGotMark.Should().BeTrue(); updated.LastThrow.PreviousPlayerPenalizedId.Should().Be(previousPlayer); } [Fact] public void ProcessThrow_ThreeEggs_RemovesOneMark() { // Arrange var playerId = Guid.NewGuid(); var previousPlayer = Guid.NewGuid(); var setup = DeathBoxGameSetup.Create(coffinSize: 12); var model = (DeathBoxGameModel)_sut.CreateInitialModel([previousPlayer, playerId], setup); model = model with { PreviousPlayerId = previousPlayer }; // Set player to have 2 eggs and 1 mark model.PlayerStates[playerId].EggCount = 2; model.PlayerStates[playerId].Marks = 1; var afterThrow = CreateAfterThrowState(playerId, 4, isCleared: true); // Act var (updatedModel, _) = _sut.ProcessThrow(model, afterThrow); // Assert var updated = (DeathBoxGameModel)updatedModel; updated.PlayerStates[playerId].EggCount.Should().Be(0); updated.PlayerStates[playerId].Marks.Should().Be(0); updated.LastThrow!.ConvertedEggsToRemoveMark.Should().BeTrue(); } [Fact] public void ProcessThrow_ThreeEggsWithNoMarks_EggsExpire() { // Arrange var playerId = Guid.NewGuid(); var previousPlayer = Guid.NewGuid(); var setup = DeathBoxGameSetup.Create(coffinSize: 12); var model = (DeathBoxGameModel)_sut.CreateInitialModel([previousPlayer, playerId], setup); model = model with { PreviousPlayerId = previousPlayer }; // Set player to have 2 eggs but 0 marks model.PlayerStates[playerId].EggCount = 2; model.PlayerStates[playerId].Marks = 0; var afterThrow = CreateAfterThrowState(playerId, 4, isCleared: true); // Act var (updatedModel, _) = _sut.ProcessThrow(model, afterThrow); // Assert var updated = (DeathBoxGameModel)updatedModel; updated.PlayerStates[playerId].EggCount.Should().Be(0); updated.PlayerStates[playerId].Marks.Should().Be(0); updated.LastThrow!.ConvertedEggsToRemoveMark.Should().BeFalse(); } #endregion #region Elimination Tests [Fact] public void ProcessThrow_PlayerReachesCoffinSize_IsEliminated() { // Arrange var playerId = Guid.NewGuid(); var otherPlayer = Guid.NewGuid(); var setup = DeathBoxGameSetup.Create(coffinSize: 3); var model = (DeathBoxGameModel)_sut.CreateInitialModel([playerId, otherPlayer], setup); // Set player to have 2 marks (one away from elimination) model.PlayerStates[playerId].Marks = 2; var afterThrow = CreateAfterThrowState(playerId, 0, isGutter: true); // Act var (updatedModel, result) = _sut.ProcessThrow(model, afterThrow); // Assert var updated = (DeathBoxGameModel)updatedModel; updated.PlayerStates[playerId].IsEliminated.Should().BeTrue(); updated.EliminatedPlayers.Should().Contain(playerId); updated.LastThrow!.PlayerEliminated.Should().BeTrue(); } [Fact] public void ProcessThrow_Elimination_TriggersExpensePoint() { // Arrange var eliminated = Guid.NewGuid(); var survivor1 = Guid.NewGuid(); var survivor2 = Guid.NewGuid(); var setup = DeathBoxGameSetup.Create(coffinSize: 3); var model = (DeathBoxGameModel)_sut.CreateInitialModel([eliminated, survivor1, survivor2], setup); model.PlayerStates[eliminated].Marks = 2; var afterThrow = CreateAfterThrowState(eliminated, 0, isGutter: true); // Act var (_, result) = _sut.ProcessThrow(model, afterThrow); // Assert - remaining players = 2, so multiplier = 2 result.Triggers.Should().Contain(t => t.TriggerType == "ExpensePoint" && t.PersonId == eliminated && t.Multiplier == 2); } [Fact] public void ProcessThrow_PreviousPlayerEliminated_ByClear() { // Arrange var currentPlayer = Guid.NewGuid(); var previousPlayer = Guid.NewGuid(); var setup = DeathBoxGameSetup.Create(coffinSize: 3); var model = (DeathBoxGameModel)_sut.CreateInitialModel([previousPlayer, currentPlayer], setup); model = model with { PreviousPlayerId = previousPlayer }; // Previous player at 2 marks model.PlayerStates[previousPlayer].Marks = 2; var afterThrow = CreateAfterThrowState(currentPlayer, 4, isCleared: true); // Act var (updatedModel, result) = _sut.ProcessThrow(model, afterThrow); // Assert var updated = (DeathBoxGameModel)updatedModel; updated.PlayerStates[previousPlayer].IsEliminated.Should().BeTrue(); updated.LastThrow!.PreviousPlayerEliminated.Should().BeTrue(); } #endregion #region Game End Tests [Fact] public void ProcessThrow_LastPlayerStanding_WinsGame() { // Arrange var player1 = Guid.NewGuid(); var player2 = Guid.NewGuid(); var setup = DeathBoxGameSetup.Create(coffinSize: 3); var model = (DeathBoxGameModel)_sut.CreateInitialModel([player1, player2], setup); // Player1 already eliminated model.PlayerStates[player1].IsEliminated = true; model.PlayerStates[player1].Marks = 3; model = model with { EliminatedPlayers = [player1] }; // Player2 throws normally var afterThrow = CreateAfterThrowState(player2, 5); // Act var (updatedModel, result) = _sut.ProcessThrow(model, afterThrow); // Assert var updated = (DeathBoxGameModel)updatedModel; updated.IsGameOver.Should().BeTrue(); updated.WinnerId.Should().Be(player2); result.IsGameOver.Should().BeTrue(); result.WinnerId.Should().Be(player2); } [Fact] public void CheckGameEnd_ReturnsFalse_WhenMultiplePlayersActive() { // Arrange var model = (DeathBoxGameModel)_sut.CreateInitialModel( [Guid.NewGuid(), Guid.NewGuid()], null); // Act var (isGameOver, winnerId) = _sut.CheckGameEnd(model); // Assert isGameOver.Should().BeFalse(); winnerId.Should().BeNull(); } [Fact] public void CheckGameEnd_ReturnsTrue_WhenGameOver() { // Arrange var winner = Guid.NewGuid(); var model = new DeathBoxGameModel { IsGameOver = true, WinnerId = winner, PlayerStates = new Dictionary { [winner] = new DeathBoxPlayerState() } }; // Act var (isGameOver, winnerId) = _sut.CheckGameEnd(model); // Assert isGameOver.Should().BeTrue(); winnerId.Should().Be(winner); } #endregion #region ValidateSetup Tests [Fact] public void ValidateSetup_AcceptsNullOptions() { // Act var result = _sut.ValidateSetup(null); // Assert result.IsValid.Should().BeTrue(); } [Theory] [InlineData(6)] [InlineData(9)] [InlineData(12)] public void ValidateSetup_AcceptsValidCoffinSize(int coffinSize) { // Arrange var setup = DeathBoxGameSetup.Create(coffinSize: coffinSize); // Act var result = _sut.ValidateSetup(setup); // Assert result.IsValid.Should().BeTrue(); } [Theory] [InlineData(0)] [InlineData(5)] [InlineData(13)] [InlineData(100)] public void ValidateSetup_RejectsInvalidCoffinSize(int coffinSize) { // Arrange var setup = new DeathBoxGameSetup { CoffinSize = coffinSize }; // Act var result = _sut.ValidateSetup(setup); // Assert result.IsValid.Should().BeFalse(); result.Errors.Should().Contain(e => e.Contains("Sarggröße")); } #endregion #region GetPlayerStats Tests [Fact] public void GetPlayerStats_ReturnsMarksAsCurrentPoints() { // Arrange var player1 = Guid.NewGuid(); var player2 = Guid.NewGuid(); var model = new DeathBoxGameModel { CoffinSize = 12, PlayerStates = new Dictionary { [player1] = new DeathBoxPlayerState { Marks = 3, XCount = 1, EggCount = 2 }, [player2] = new DeathBoxPlayerState { Marks = 7, XCount = 0, EggCount = 0 } } }; // Act var stats = _sut.GetPlayerStats(model); // Assert stats[player1].CurrentPoints.Should().Be(3); stats[player1].CustomStats["XCount"].Should().Be(1); stats[player1].CustomStats["EggCount"].Should().Be(2); stats[player2].CurrentPoints.Should().Be(7); } [Fact] public void GetPlayerStats_IndicatesWinnerAndEliminated() { // Arrange var winner = Guid.NewGuid(); var loser = Guid.NewGuid(); var model = new DeathBoxGameModel { CoffinSize = 12, IsGameOver = true, WinnerId = winner, EliminatedPlayers = [loser], PlayerStates = new Dictionary { [winner] = new DeathBoxPlayerState { Marks = 5, IsEliminated = false }, [loser] = new DeathBoxPlayerState { Marks = 12, IsEliminated = true } } }; // Act var stats = _sut.GetPlayerStats(model); // Assert stats[winner].CustomStats["IsWinner"].Should().Be(true); stats[winner].CustomStats["IsEliminated"].Should().Be(false); stats[loser].CustomStats["IsWinner"].Should().Be(false); stats[loser].CustomStats["IsEliminated"].Should().Be(true); } #endregion #region GetAvailableActions Tests [Fact] public void GetAvailableActions_ReturnsEmpty() { // Arrange - DeathBox has no custom actions var playerId = Guid.NewGuid(); var model = (DeathBoxGameModel)_sut.CreateInitialModel([playerId], null); // Act var actions = _sut.GetAvailableActions(model, playerId); // Assert actions.Should().BeEmpty(); } #endregion #region Edge Cases [Fact] public void ProcessThrow_DoesNothing_WhenGameAlreadyOver() { // Arrange var playerId = Guid.NewGuid(); var model = new DeathBoxGameModel { IsGameOver = true, WinnerId = Guid.NewGuid(), CoffinSize = 12, PlayerStates = new Dictionary { [playerId] = new DeathBoxPlayerState { Marks = 5 } }, PlayerOrder = [playerId] }; var afterThrow = CreateAfterThrowState(playerId, 0, isGutter: true); // Act var (updatedModel, result) = _sut.ProcessThrow(model, afterThrow); // Assert var updated = (DeathBoxGameModel)updatedModel; updated.PlayerStates[playerId].Marks.Should().Be(5); // Unchanged result.IsGameOver.Should().BeTrue(); result.ShouldRotatePlayer.Should().BeFalse(); } [Fact] public void ProcessThrow_SkipsEliminatedPlayers_WhenRotating() { // Arrange var player1 = Guid.NewGuid(); var player2 = Guid.NewGuid(); var player3 = Guid.NewGuid(); var setup = DeathBoxGameSetup.Create(coffinSize: 12, randomizePlayerOrder: false); var model = (DeathBoxGameModel)_sut.CreateInitialModel([player1, player2, player3], setup); // Player2 is eliminated model.PlayerStates[player2].IsEliminated = true; model = model with { CurrentPlayerIndex = 0, EliminatedPlayers = [player2] }; var afterThrow = CreateAfterThrowState(player1, 5); // Act var (updatedModel, result) = _sut.ProcessThrow(model, afterThrow); // Assert var updated = (DeathBoxGameModel)updatedModel; // Should skip player2 (index 1) and go to player3 (index 2) result.Overrides!.NextPlayerId.Should().Be(player3); } #endregion #region Helper Methods private static AfterThrowState CreateAfterThrowState( Guid playerId, int pinsKnocked, bool isNewRound = false, bool isGutter = false, bool isNoWood = false, bool isCleared = false) { var pins = new PinStatus[9]; if (isCleared) { // All pins are now fallen after this throw (cleared) for (var i = 0; i < 9; i++) { pins[i] = PinStatus.Fallen; } } else if (isGutter || isNoWood) { // All pins still standing for (var i = 0; i < 9; i++) { pins[i] = PinStatus.Standing; } } else if (isNewRound) { // New round: 9 pins were standing, pinsKnocked were knocked down // After throw: 9 - pinsKnocked are still standing for (var i = 0; i < 9; i++) { pins[i] = i < (9 - pinsKnocked) ? PinStatus.Standing : PinStatus.Fallen; } } else { // Normal throw: some pins knocked for (var i = 0; i < 9; i++) { pins[i] = i < pinsKnocked ? PinStatus.Fallen : PinStatus.Standing; } } var throwPanel = new ThrowPanelSnapshot { Pins = pins, ThrowsPerRound = 1, ThrowCounterPerRound = 1, TotalThrowCounter = 1, ThrowMode = ThrowMode.Decrease, BellValue = false }; return new AfterThrowState( ThrowPanel: throwPanel, CurrentPlayerId: playerId, PinsKnocked: pinsKnocked, IsCircle: false, IsStrike: pinsKnocked == 9 && isNewRound, IsGutter: isGutter, IsCleared: isCleared ); } #endregion }