@@ -7,71 +7,50 @@ defmodule CopiWeb.ApiController do
77 def play_card ( conn , % { "game_id" => game_id , "player_id" => player_id , "dealt_card_id" => dealt_card_id } ) do
88 with { :ok , game } <- Game . find ( game_id ) do
99 player = Enum . find ( game . players , fn player -> player . id == player_id end )
10-
11- if player do
12- dealt_card = Enum . find ( player . dealt_cards , fn dealt_card -> Integer . to_string ( dealt_card . id ) == dealt_card_id end )
10+ dealt_card = Enum . find ( player . dealt_cards , fn dealt_card -> Integer . to_string ( dealt_card . id ) == dealt_card_id end )
1311
14- if dealt_card do
15- current_round = game . rounds_played + 1
12+ if player && dealt_card do
13+ current_round = game . rounds_played + 1
14+
15+ cond do
16+ dealt_card . played_in_round ->
17+ conn |> put_status ( :not_acceptable ) |> json ( % { "error" => "Card already played" } )
18+ Enum . find ( player . dealt_cards , fn dealt_card -> dealt_card . played_in_round == current_round end ) ->
19+ conn |> put_status ( :forbidden ) |> json ( % { "error" => "Player already played a card in this round" } )
20+ true ->
21+ # Atomic update to prevent race conditions
22+ case play_card_atomically ( dealt_card , current_round ) do
23+ { :ok , updated_card } ->
24+ with { :ok , updated_game } <- Game . find ( game . id ) do
25+ CopiWeb.Endpoint . broadcast ( topic ( game . id ) , "game:updated" , updated_game )
26+ else
27+ { :error , _reason } ->
28+ conn |> put_status ( :internal_server_error ) |> json ( % { "error" => "Could not find updated game" } )
29+ end
1630
17- # Atomic update to prevent race conditions and enforce one-card-per-round invariant
18- case play_card_atomically ( dealt_card , player . id , current_round ) do
19- { :ok , updated_card } ->
20- with { :ok , updated_game } <- Game . find ( game . id ) do
21- CopiWeb.Endpoint . broadcast ( topic ( game . id ) , "game:updated" , updated_game )
2231 conn |> json ( % { "id" => updated_card . id } )
23- else
24- { :error , _reason } ->
25- conn |> put_status ( :internal_server_error ) |> json ( % { "error" => "Could not find updated game" } )
26- end
27- { :error , :already_played } ->
28- conn |> put_status ( :conflict ) |> json ( % { "error" => "Card was already played by another request" } )
29- { :error , :player_already_played } ->
30- conn |> put_status ( :forbidden ) |> json ( % { "error" => "Player already played a card in this round" } )
31- { :error , _changeset } ->
32- conn |> put_status ( :internal_server_error ) |> json ( % { "error" => "Could not update card" } )
33- end
34- else
35- conn |> put_status ( :not_found ) |> json ( % { "error" => "Could not find dealt card" } )
32+ { :error , :already_played } ->
33+ conn |> put_status ( :conflict ) |> json ( % { "error" => "Card was already played by another request" } )
34+ { :error , _changeset } ->
35+ conn |> put_status ( :internal_server_error ) |> json ( % { "error" => "Could not update dealt card" } )
36+ end
3637 end
3738 else
38- conn |> put_status ( :not_found ) |> json ( % { "error" => "Could not find player " } )
39+ conn |> put_status ( :not_found ) |> json ( % { "error" => "Player not found in this game " } )
3940 end
4041 else
4142 { :error , _reason } -> conn |> put_status ( :not_found ) |> json ( % { "error" => "Could not find game" } )
4243 end
4344 end
4445
45- # Atomic card play operation to prevent race conditions and enforce one-card-per-round invariant
46- defp play_card_atomically ( dealt_card , player_id , current_round ) do
47- # Use database transaction to ensure atomicity and prevent race conditions
48- result = Repo . transaction ( fn ->
49- # First, try to lock player's cards for this round by checking existing plays
50- existing_cards = Repo . all (
51- from ( dc in Copi.Cornucopia.DealtCard ,
52- where: dc . player_id == ^ player_id and dc . played_in_round == ^ current_round )
53- )
54-
55- if length ( existing_cards ) > 0 do
56- { :error , :player_already_played }
57- else
58- # Now atomically update the card with played_in_round
59- from ( dc in Copi.Cornucopia.DealtCard ,
60- where: dc . id == ^ dealt_card . id and is_nil ( dc . played_in_round ) )
61- |> Repo . update_all ( [ set: [ played_in_round: current_round ] ] , returning: true )
62- |> case do
63- { 1 , [ updated_card ] } -> { :ok , updated_card }
64- { 0 , [ ] } -> { :error , :already_played }
65- end
66- end
67- end )
68-
69- # Unwrap transaction result to return flat tuple
70- case result do
71- { :ok , { :ok , val } } -> { :ok , val }
72- { :ok , { :error , reason } } -> { :error , reason }
73- { :error , reason } -> { :error , reason }
74- other -> other
46+ # Atomic card play operation to prevent race conditions
47+ defp play_card_atomically ( dealt_card , current_round ) do
48+ # Use Ecto's atomic operations to prevent race conditions
49+ from ( dc in Copi.Cornucopia.DealtCard , where: dc . id == ^ dealt_card . id and is_nil ( dc . played_in_round ) )
50+ |> Repo . update_all ( [ set: [ played_in_round: current_round ] ] , returning: true )
51+ |> case do
52+ { 1 , [ updated_card ] } -> { :ok , updated_card }
53+ { 0 , [ ] } -> { :error , :already_played }
7554 end
7655 end
7756 def topic ( game_id ) do
0 commit comments