Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions PointingParty.Client.Tests/GameAggregateTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
using PointingParty.Domain;
using PointingParty.Domain.Events;

namespace PointingParty.Client.Tests;

public class GameAggregateTests
{
private const string GameId = "TestGame";
private const string PlayerName = "Player";

[Fact]
public void Votes_Cast_After_Reveal_Are_Dropped()
{
var game = new GameAggregate(GameId, PlayerName);
game.Handle(new PlayerJoinedGame(GameId, "Other"));
game.Handle(new VoteCast(GameId, "Other", 2));
game.Handle(new VotesShown(GameId));

// A vote that arrives after the round was revealed must not change it.
game.Handle(new VoteCast(GameId, "Other", 5));

Assert.Equal(new Vote(2), game.State.PlayerVotes["Other"]);
}

[Fact]
public void VotesShown_Carries_ShowConfetti_Flag()
{
var game = new GameAggregate(GameId, PlayerName);

game.Handle(new VotesShown(GameId, ShowConfetti: true));

Assert.True(game.State.ShowVotes);
Assert.True(game.State.ShowConfetti);
}

[Fact]
public void Reset_Clears_ShowConfetti()
{
var game = new GameAggregate(GameId, PlayerName);
game.Handle(new VotesShown(GameId, ShowConfetti: true));

game.Handle(new GameReset(GameId));

Assert.False(game.State.ShowVotes);
Assert.False(game.State.ShowConfetti);
}
}
86 changes: 86 additions & 0 deletions PointingParty.Client.Tests/GameStateExtensionsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -56,4 +56,90 @@ public void Returns_Default_When_Nobody_Voted(VoteStatus vote)

Assert.Equal(default, state.AverageVote());
}

[Fact]
public void IsUnanimous_When_Everyone_Voted_The_Same_Number()
{
var state = StateWith(
("a", 2),
("b", 2),
("c", 2));

Assert.True(state.IsUnanimous());
}

[Fact]
public void Is_Not_Unanimous_When_A_Vote_Differs()
{
// The scenario reported in the issue: 2, 2, 2, 1
var state = StateWith(
("a", 2),
("b", 2),
("c", 2),
("d", 1));

Assert.False(state.IsUnanimous());
}

[Theory]
[InlineData(VoteStatus.Pending)]
[InlineData(VoteStatus.Coffee)]
[InlineData(VoteStatus.Question)]
public void Ignores_Abstaining_Players(VoteStatus abstention)
{
// Players who did not cast a numeric vote are ignored, so the remaining
// numeric votes still count as unanimous.
var state = StateWith(
("a", 2),
("b", 2),
("c", abstention));

Assert.True(state.IsUnanimous());
}

[Theory]
[InlineData(VoteStatus.Pending)]
[InlineData(VoteStatus.Coffee)]
[InlineData(VoteStatus.Question)]
public void Is_Not_Unanimous_When_A_Numeric_Vote_Differs_Despite_An_Abstention(VoteStatus abstention)
{
// The scenario reported in the issue: 2, 2, 2, 1, abstained
var state = StateWith(
("a", 2),
("b", 2),
("c", 2),
("d", 1),
("e", abstention));

Assert.False(state.IsUnanimous());
}

[Theory]
[InlineData(VoteStatus.Pending)]
[InlineData(VoteStatus.Coffee)]
[InlineData(VoteStatus.Question)]
public void Is_Not_Unanimous_When_Nobody_Cast_A_Numeric_Vote(VoteStatus vote)
{
var state = StateWith(
("a", vote),
("b", vote));

Assert.False(state.IsUnanimous());
}

[Fact]
public void Is_Not_Unanimous_With_A_Single_Player()
{
var state = StateWith(("a", 2));

Assert.False(state.IsUnanimous());
}

private static GameState StateWith(params (string Player, Vote Vote)[] votes)
{
return new GameState(
string.Empty,
votes.ToImmutableDictionary(x => x.Player, x => x.Vote),
true);
}
}
44 changes: 20 additions & 24 deletions PointingParty.Client/Components/GameUi.razor
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@

private GameAggregate Game => GameContext.Game ?? new GameAggregate();

private bool _previousShowVotes;
private bool _confettiShown;

protected override void OnInitialized()
{
Expand All @@ -108,39 +108,35 @@

protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (Game.State.ShowVotes && !_previousShowVotes)
if (!Game.State.ShowVotes)
{
_previousShowVotes = true;
await TriggerConfettiIfUnanimous();
_confettiShown = false;
return;
}
else if (!Game.State.ShowVotes)

// Whether to celebrate is decided once by the player who reveals the
// votes (see ShowVotes) and broadcast via the VotesShown event, so every
// client agrees regardless of the order votes and the reveal arrive in.
if (!_confettiShown && Game.State.ShowConfetti)
{
_previousShowVotes = false;
_confettiShown = true;
await TriggerConfetti();
}
}

private static readonly string[] ConfettiShapes = ["circle", "square", "star"];
private static readonly Random Random = new();

private async Task TriggerConfettiIfUnanimous()
private async Task TriggerConfetti()
{
var nonPendingVotes = Game.State.PlayerVotes.Values
.Where(v => v.Status != VoteStatus.Pending)
.ToList();

if (nonPendingVotes.Count > 1 && nonPendingVotes.All(v => v.Equals(nonPendingVotes[0])))
var shape = ConfettiShapes[Random.Next(ConfettiShapes.Length)];
await JS.InvokeVoidAsync("confetti", new
{
var totalPlayers = Game.State.PlayerVotes.Count;
var particleCount = (int)(30 + 170 * (nonPendingVotes.Count / (double)totalPlayers));
var shape = ConfettiShapes[Random.Next(ConfettiShapes.Length)];
await JS.InvokeVoidAsync("confetti", new
{
particleCount,
spread = 80,
origin = new { y = 0.8 },
shapes = new[] { shape }
});
}
particleCount = 200,
spread = 80,
origin = new { y = 0.8 },
shapes = new[] { shape }
});
}

private async Task Vote(double score)
Expand Down Expand Up @@ -169,7 +165,7 @@

private void ShowVotes()
{
Game.VotesShown();
Game.VotesShown(Game.State.IsUnanimous());
GameContext.PublishEvents();
}
}
17 changes: 17 additions & 0 deletions PointingParty.Client/GameStateExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,21 @@ public static double AverageVote(this GameState gameState)
.ToList();
return scoredVotes.Any() ? scoredVotes.Average() : default;
}

/// <summary>
/// Returns true when more than one player cast a numeric vote and every such
/// vote is the same. Players who abstained — i.e. did not vote (Pending) or
/// picked a non-numeric option (Coffee, Question) — are ignored.
/// This is evaluated by the player who reveals the votes (who has the full
/// picture) so the result is decided once and broadcast, rather than each
/// client re-deciding against its own, possibly incomplete, state.
/// </summary>
public static bool IsUnanimous(this GameState gameState)
{
var scoredVotes = gameState.PlayerVotes.Values
.Where(v => v.Status == VoteStatus.Scored)
.ToList();

return scoredVotes.Count > 1 && scoredVotes.All(v => v.Equals(scoredVotes[0]));
}
}
2 changes: 1 addition & 1 deletion PointingParty.Domain/Events/VotesShown.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
namespace PointingParty.Domain.Events;

public record VotesShown(string GameId) : IGameEvent;
public record VotesShown(string GameId, bool ShowConfetti = false) : IGameEvent;
13 changes: 9 additions & 4 deletions PointingParty.Domain/GameAggregate.cs
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,8 @@ private void Apply(GameReset e)
State = State with
{
PlayerVotes = State.PlayerVotes.ToImmutableDictionary(pv => pv.Key, _ => new Vote(VoteStatus.Pending)),
ShowVotes = false
ShowVotes = false,
ShowConfetti = false
};
}

Expand Down Expand Up @@ -103,6 +104,10 @@ private void Apply(Sync e)

private void Apply(VoteCast e)
{
// Once votes are revealed the round is locked; drop any further votes
// so a late or post-reveal vote can't alter the result.
if (State.ShowVotes) return;

State = State with
{
PlayerVotes = State.PlayerVotes.SetItem(e.PlayerName, e.Vote)
Expand All @@ -111,7 +116,7 @@ private void Apply(VoteCast e)

private void Apply(VotesShown e)
{
State = State with { ShowVotes = true };
State = State with { ShowVotes = true, ShowConfetti = e.ShowConfetti };
}

public void GameReset()
Expand Down Expand Up @@ -143,9 +148,9 @@ public void VoteCast(Vote vote)
EventsToPublish.Add(voteCastEvent);
}

public void VotesShown()
public void VotesShown(bool showConfetti)
{
var showVotesEvent = new VotesShown(State.GameId);
var showVotesEvent = new VotesShown(State.GameId, showConfetti);
Apply(showVotesEvent);
EventsToPublish.Add(showVotesEvent);
}
Expand Down
3 changes: 2 additions & 1 deletion PointingParty.Domain/GameState.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@

namespace PointingParty.Domain;

public record GameState(string GameId, ImmutableDictionary<string, Vote> PlayerVotes, bool ShowVotes);
public record GameState(string GameId, ImmutableDictionary<string, Vote> PlayerVotes, bool ShowVotes,
bool ShowConfetti = false);
Loading