diff --git a/PointingParty.Client.Tests/GameAggregateTests.cs b/PointingParty.Client.Tests/GameAggregateTests.cs
new file mode 100644
index 0000000..3b3ca62
--- /dev/null
+++ b/PointingParty.Client.Tests/GameAggregateTests.cs
@@ -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);
+ }
+}
diff --git a/PointingParty.Client.Tests/GameStateExtensionsTests.cs b/PointingParty.Client.Tests/GameStateExtensionsTests.cs
index 1671d9b..c7f0257 100644
--- a/PointingParty.Client.Tests/GameStateExtensionsTests.cs
+++ b/PointingParty.Client.Tests/GameStateExtensionsTests.cs
@@ -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);
+ }
}
diff --git a/PointingParty.Client/Components/GameUi.razor b/PointingParty.Client/Components/GameUi.razor
index 20b3ede..fd520ef 100644
--- a/PointingParty.Client/Components/GameUi.razor
+++ b/PointingParty.Client/Components/GameUi.razor
@@ -98,7 +98,7 @@
private GameAggregate Game => GameContext.Game ?? new GameAggregate();
- private bool _previousShowVotes;
+ private bool _confettiShown;
protected override void OnInitialized()
{
@@ -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)
@@ -169,7 +165,7 @@
private void ShowVotes()
{
- Game.VotesShown();
+ Game.VotesShown(Game.State.IsUnanimous());
GameContext.PublishEvents();
}
}
diff --git a/PointingParty.Client/GameStateExtensions.cs b/PointingParty.Client/GameStateExtensions.cs
index 337aa04..78bc5d7 100644
--- a/PointingParty.Client/GameStateExtensions.cs
+++ b/PointingParty.Client/GameStateExtensions.cs
@@ -12,4 +12,21 @@ public static double AverageVote(this GameState gameState)
.ToList();
return scoredVotes.Any() ? scoredVotes.Average() : default;
}
+
+ ///
+ /// 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.
+ ///
+ 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]));
+ }
}
diff --git a/PointingParty.Domain/Events/VotesShown.cs b/PointingParty.Domain/Events/VotesShown.cs
index b19e2ea..b44bdd7 100644
--- a/PointingParty.Domain/Events/VotesShown.cs
+++ b/PointingParty.Domain/Events/VotesShown.cs
@@ -1,3 +1,3 @@
namespace PointingParty.Domain.Events;
-public record VotesShown(string GameId) : IGameEvent;
\ No newline at end of file
+public record VotesShown(string GameId, bool ShowConfetti = false) : IGameEvent;
\ No newline at end of file
diff --git a/PointingParty.Domain/GameAggregate.cs b/PointingParty.Domain/GameAggregate.cs
index a9dc533..2b27ddc 100644
--- a/PointingParty.Domain/GameAggregate.cs
+++ b/PointingParty.Domain/GameAggregate.cs
@@ -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
};
}
@@ -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)
@@ -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()
@@ -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);
}
diff --git a/PointingParty.Domain/GameState.cs b/PointingParty.Domain/GameState.cs
index a38d03e..11a740a 100644
--- a/PointingParty.Domain/GameState.cs
+++ b/PointingParty.Domain/GameState.cs
@@ -2,4 +2,5 @@
namespace PointingParty.Domain;
-public record GameState(string GameId, ImmutableDictionary PlayerVotes, bool ShowVotes);
\ No newline at end of file
+public record GameState(string GameId, ImmutableDictionary PlayerVotes, bool ShowVotes,
+ bool ShowConfetti = false);
\ No newline at end of file