KoogleApp/test/Koogle.Tests/Application/Games/DeathBoxGameLogicServiceTes...

746 lines
23 KiB
C#

using FluentAssertions;
using Koogle.Application.Games;
using Koogle.Application.Games.DeathBox;
using Koogle.Domain.Enums;
using Xunit;
namespace Koogle.Tests.Application.Games;
/// <summary>
/// Unit tests for DeathBoxGameLogicService.
/// </summary>
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<DeathBoxGameModel>();
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<Guid, DeathBoxPlayerState>
{
[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<Guid, DeathBoxPlayerState>
{
[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<Guid, DeathBoxPlayerState>
{
[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<Guid, DeathBoxPlayerState>
{
[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
}