+3
-7
.cursorrules
+3
-7
.cursorrules
···
13
13
14
14
Do not provide code samples. Edit files directly and input the proper contents there.
15
15
16
-
After a response, provide three follow-up questions worded as if I'm asking you to work through the logic as my pair.
17
-
18
-
Format in bold as Q1, Q2, Q3. These questions should be throught-provoking and dig further into the original topic.
19
-
20
16
If my response starts with "XX", give the most succinct, concise, shortest answer possible
21
17
22
18
Code Style and Structure
···
46
42
- Implement responsive design with Tailwind CSS.
47
43
- Implement subtle javascript microinteractions as appropriate using Tailwind + Phoenix.LiveView.JS
48
44
- Use Phoenix view helpers and templates to keep views DRY using @core_components.ex.
49
-
45
+
50
46
Performance Optimization
51
47
- Use database indexing effectively.
52
48
- Implement caching strategies using Nebulex
53
49
- Use Ecto's preload to avoid N+1 queries.
54
50
- Optimize database queries using preload, joins, or select.
55
-
51
+
56
52
Testing
57
53
- Write comprehensive tests using ExUnit.
58
54
- Follow TDD practices.
59
55
- Use ExMachina for test data generation.
60
-
56
+
61
57
Follow the official Phoenix guides for best practices in routing, controllers, contexts, views, and other Phoenix components.
62
58
63
59
Reuse code as appropriate, but also remember that premature abstraction and DRY can be problematic in their own right. If we are going to reuse code, we should do our best to not need to modify existing interfaces
+6
-1
lib/blog/application.ex
+6
-1
lib/blog/application.ex
···
43
43
# Start the Wordle stores
44
44
Blog.Wordle.WordStore,
45
45
Blog.Wordle.GameStore,
46
+
# Start the Presence service for real-time user tracking
47
+
BlogWeb.Presence,
46
48
]
47
49
48
50
# Pre-load the Games modules to ensure they're available
···
75
77
# Start the Wordle stores
76
78
Blog.Wordle.WordStore,
77
79
Blog.Wordle.GameStore,
80
+
# Start the Presence service for real-time user tracking
81
+
BlogWeb.Presence,
78
82
]
79
83
80
84
opts = [strategy: :one_for_one, name: Blog.Supervisor]
···
87
91
Enum.each([
88
92
{:reddit_links, [:ordered_set, :public, read_concurrency: true]},
89
93
{:bookmarks_table, [:set, :public, read_concurrency: true]},
90
-
{:pong_games, [:set, :public]}
94
+
{:pong_games, [:set, :public]},
95
+
{:war_players, [:set, :public, read_concurrency: true, write_concurrency: true]}
91
96
], fn {table_name, table_opts} ->
92
97
# Only create if it doesn't exist
93
98
if :ets.whereis(table_name) == :undefined do
+666
lib/blog_web/live/war_live.ex
+666
lib/blog_web/live/war_live.ex
···
1
+
defmodule BlogWeb.WarLive do
2
+
use BlogWeb, :live_view
3
+
alias BlogWeb.Presence
4
+
require Logger
5
+
6
+
@topic "war:lobby"
7
+
@ping_interval 5000
8
+
@ets_table :war_players
9
+
10
+
@impl true
11
+
def mount(_params, session, socket) do
12
+
# For testing purposes, generate a unique random ID for each tab
13
+
# Use the timestamp + random number to ensure uniqueness
14
+
timestamp = DateTime.utc_now() |> DateTime.to_unix()
15
+
random_suffix = :rand.uniform(1000)
16
+
tab_unique_id = "user_#{timestamp}_#{random_suffix}"
17
+
18
+
# Either use existing user_id from session or create a new unique one
19
+
user_id = session["user_id"] || tab_unique_id
20
+
21
+
# Generate a random display name for this user as default
22
+
display_name = generate_display_name()
23
+
24
+
if connected?(socket) do
25
+
# Subscribe to presence updates and ping topic
26
+
Phoenix.PubSub.subscribe(Blog.PubSub, @topic)
27
+
28
+
# Track user in presence with a unique mnemonic name for easier testing
29
+
{:ok, _} = Presence.track(self(), @topic, user_id, %{
30
+
online_at: timestamp,
31
+
status: "available",
32
+
display_name: display_name
33
+
})
34
+
35
+
# Also store in ETS for persistence across all sessions
36
+
store_player_in_ets(user_id, %{
37
+
online_at: timestamp,
38
+
status: "available",
39
+
display_name: display_name
40
+
})
41
+
42
+
# Broadcast player joined to ensure all clients update
43
+
Phoenix.PubSub.broadcast!(Blog.PubSub, @topic, {:player_joined, user_id})
44
+
45
+
# Set up ping interval to keep presence fresh
46
+
:timer.send_interval(@ping_interval, :ping)
47
+
end
48
+
49
+
# Get ALL players from ETS
50
+
all_players = get_all_players_from_ets()
51
+
52
+
socket = socket
53
+
|> assign(:user_id, user_id)
54
+
|> assign(:players, all_players)
55
+
|> assign(:game_state, nil)
56
+
|> assign(:invitations, %{})
57
+
|> assign(:sent_invitations, %{})
58
+
|> assign(:card_deck, nil)
59
+
|> assign(:edit_name, false)
60
+
|> assign(:name_form, %{"display_name" => display_name})
61
+
62
+
{:ok, socket}
63
+
end
64
+
65
+
# Generate a random display name for easier identification during testing
66
+
defp generate_display_name do
67
+
# Lists of adjectives and animals to create a readable identifier
68
+
adjectives = ~w(Red Blue Green Yellow Purple Orange Tiny Big Fast Slow Happy Silly Smart Brave Wild Calm)
69
+
animals = ~w(Lion Tiger Bear Wolf Fox Panda Koala Eagle Shark Dolphin Rabbit Turtle Elephant Giraffe Kangaroo)
70
+
71
+
adjective = Enum.random(adjectives)
72
+
animal = Enum.random(animals)
73
+
74
+
"#{adjective}#{animal}"
75
+
end
76
+
77
+
# Helper function to get the display name for a player
78
+
def player_display_name(nil), do: "Unknown Player"
79
+
def player_display_name(player) when is_map(player) do
80
+
Map.get(player, :display_name, "Player")
81
+
end
82
+
83
+
# View helper functions for card display
84
+
def card_color("hearts"), do: "text-red-600"
85
+
def card_color("diamonds"), do: "text-red-600"
86
+
def card_color("clubs"), do: "text-gray-800"
87
+
def card_color("spades"), do: "text-gray-800"
88
+
89
+
def display_card_value("jack"), do: "J"
90
+
def display_card_value("queen"), do: "Q"
91
+
def display_card_value("king"), do: "K"
92
+
def display_card_value("ace"), do: "A"
93
+
def display_card_value(value), do: value
94
+
95
+
def display_card_suit("hearts"), do: "♥"
96
+
def display_card_suit("diamonds"), do: "♦"
97
+
def display_card_suit("clubs"), do: "♣"
98
+
def display_card_suit("spades"), do: "♠"
99
+
100
+
def time_ago(timestamp) do
101
+
now = DateTime.utc_now() |> DateTime.to_unix()
102
+
diff = now - timestamp
103
+
104
+
cond do
105
+
diff < 60 -> "#{diff} seconds"
106
+
diff < 3600 -> "#{div(diff, 60)} minutes"
107
+
diff < 86400 -> "#{div(diff, 3600)} hours"
108
+
true -> "#{div(diff, 86400)} days"
109
+
end
110
+
end
111
+
112
+
# Store player in ETS for persistence across all sessions
113
+
defp store_player_in_ets(user_id, player_data) do
114
+
:ets.insert(@ets_table, {user_id, player_data})
115
+
end
116
+
117
+
# Remove player from ETS
118
+
defp remove_player_from_ets(user_id) do
119
+
:ets.delete(@ets_table, user_id)
120
+
end
121
+
122
+
# Get all players from ETS
123
+
defp get_all_players_from_ets do
124
+
case :ets.tab2list(@ets_table) do
125
+
[] -> %{}
126
+
players ->
127
+
players
128
+
|> Enum.map(fn {user_id, data} -> {user_id, data} end)
129
+
|> Map.new()
130
+
end
131
+
end
132
+
133
+
# Handle name editing
134
+
@impl true
135
+
def handle_event("toggle_edit_name", _, socket) do
136
+
{:noreply, assign(socket, :edit_name, !socket.assigns.edit_name)}
137
+
end
138
+
139
+
@impl true
140
+
def handle_event("change_name_form", %{"display_name" => display_name}, socket) do
141
+
{:noreply, assign(socket, :name_form, %{"display_name" => display_name})}
142
+
end
143
+
144
+
@impl true
145
+
def handle_event("save_display_name", %{"display_name" => display_name}, socket) do
146
+
# Validate name is not empty
147
+
display_name = String.trim(display_name)
148
+
display_name = if display_name == "", do: generate_display_name(), else: display_name
149
+
150
+
# Update presence with new name
151
+
user_id = socket.assigns.user_id
152
+
current_meta = Map.get(socket.assigns.players, user_id, %{})
153
+
154
+
updated_meta = Map.put(current_meta, :display_name, display_name)
155
+
156
+
# Update in Presence
157
+
{:ok, _} = Presence.update(self(), @topic, user_id, updated_meta)
158
+
159
+
# Update in ETS
160
+
store_player_in_ets(user_id, updated_meta)
161
+
162
+
# Broadcast name change to all clients
163
+
Phoenix.PubSub.broadcast!(Blog.PubSub, @topic, {:name_changed, user_id, display_name})
164
+
165
+
# Update local assigns
166
+
all_players = get_all_players_from_ets()
167
+
168
+
socket = socket
169
+
|> assign(:players, all_players)
170
+
|> assign(:edit_name, false)
171
+
172
+
{:noreply, socket}
173
+
end
174
+
175
+
@impl true
176
+
def handle_event("invite_player", %{"id" => player_id}, socket) do
177
+
invitation = %{
178
+
from: socket.assigns.user_id,
179
+
to: player_id,
180
+
timestamp: DateTime.utc_now() |> DateTime.to_unix()
181
+
}
182
+
183
+
# Broadcast invitation - only the recipient will store it
184
+
Phoenix.PubSub.broadcast!(Blog.PubSub, @topic, {:invitation, invitation})
185
+
186
+
# Add a sent_invitations list to track outgoing invitations for UI feedback
187
+
sent_invitations = Map.get(socket.assigns, :sent_invitations, %{})
188
+
socket = socket
189
+
|> assign(:sent_invitations, Map.put(sent_invitations, player_id, invitation))
190
+
191
+
{:noreply, socket}
192
+
end
193
+
194
+
@impl true
195
+
def handle_event("accept_invitation", %{"from" => from_id}, socket) do
196
+
# Get invitation
197
+
invitation = socket.assigns.invitations[from_id]
198
+
199
+
if invitation do
200
+
# Start new game
201
+
game_state = start_new_game(from_id, socket.assigns.user_id)
202
+
203
+
# Broadcast game start
204
+
Phoenix.PubSub.broadcast!(Blog.PubSub, @topic, {:game_started, game_state})
205
+
206
+
# Update socket
207
+
socket = socket
208
+
|> assign(:game_state, game_state)
209
+
|> assign(:invitations, Map.drop(socket.assigns.invitations, [from_id]))
210
+
211
+
{:noreply, socket}
212
+
else
213
+
{:noreply, socket}
214
+
end
215
+
end
216
+
217
+
@impl true
218
+
def handle_event("decline_invitation", %{"from" => from_id}, socket) do
219
+
# Remove invitation
220
+
socket = socket
221
+
|> assign(:invitations, Map.drop(socket.assigns.invitations, [from_id]))
222
+
223
+
# Broadcast decline
224
+
Phoenix.PubSub.broadcast!(Blog.PubSub, @topic, {:invitation_declined, %{
225
+
from: from_id,
226
+
to: socket.assigns.user_id
227
+
}})
228
+
229
+
{:noreply, socket}
230
+
end
231
+
232
+
@impl true
233
+
def handle_event("play_card", _params, socket) do
234
+
game_state = socket.assigns.game_state
235
+
236
+
if game_state do
237
+
# Update game state based on card play
238
+
updated_game = play_round(game_state, socket.assigns.user_id)
239
+
240
+
# Broadcast game update
241
+
Phoenix.PubSub.broadcast!(Blog.PubSub, @topic, {:game_updated, updated_game})
242
+
243
+
socket = socket |> assign(:game_state, updated_game)
244
+
{:noreply, socket}
245
+
else
246
+
{:noreply, socket}
247
+
end
248
+
end
249
+
250
+
@impl true
251
+
def handle_event("continue_round", _params, socket) do
252
+
game_state = socket.assigns.game_state
253
+
254
+
if game_state && game_state.scoring_phase do
255
+
# Now resolve the round
256
+
updated_game = resolve_round(game_state)
257
+
258
+
# Broadcast game update
259
+
Phoenix.PubSub.broadcast!(Blog.PubSub, @topic, {:game_updated, updated_game})
260
+
261
+
socket = socket |> assign(:game_state, updated_game)
262
+
{:noreply, socket}
263
+
else
264
+
{:noreply, socket}
265
+
end
266
+
end
267
+
268
+
@impl true
269
+
def handle_info({:name_changed, user_id, _display_name}, socket) do
270
+
# When any player changes their name, refresh the player list
271
+
all_players = get_all_players_from_ets()
272
+
273
+
socket = socket |> assign(:players, all_players)
274
+
{:noreply, socket}
275
+
end
276
+
277
+
@impl true
278
+
def handle_info({:player_joined, _user_id}, socket) do
279
+
# When any player joins, refresh the complete player list from ETS
280
+
all_players = get_all_players_from_ets()
281
+
socket = socket |> assign(:players, all_players)
282
+
{:noreply, socket}
283
+
end
284
+
285
+
@impl true
286
+
def handle_info({:player_left, _user_id}, socket) do
287
+
# When any player leaves, refresh the complete player list from ETS
288
+
all_players = get_all_players_from_ets()
289
+
socket = socket |> assign(:players, all_players)
290
+
{:noreply, socket}
291
+
end
292
+
293
+
@impl true
294
+
def handle_info({:invitation, invitation}, socket) do
295
+
# Only process invitations sent to this user
296
+
if invitation.to == socket.assigns.user_id do
297
+
socket = socket
298
+
|> assign(:invitations, Map.put(socket.assigns.invitations, invitation.from, invitation))
299
+
300
+
{:noreply, socket}
301
+
else
302
+
{:noreply, socket}
303
+
end
304
+
end
305
+
306
+
@impl true
307
+
def handle_info({:invitation_declined, invitation}, socket) do
308
+
# Only process invitations sent by this user
309
+
if invitation.from == socket.assigns.user_id do
310
+
socket = socket
311
+
|> assign(:sent_invitations, Map.drop(socket.assigns.sent_invitations, [invitation.to]))
312
+
313
+
{:noreply, socket}
314
+
else
315
+
{:noreply, socket}
316
+
end
317
+
end
318
+
319
+
@impl true
320
+
def handle_info({:game_started, game_state}, socket) do
321
+
# Only process game starts that involve this user
322
+
if game_state.player1 == socket.assigns.user_id || game_state.player2 == socket.assigns.user_id do
323
+
socket = socket
324
+
|> assign(:game_state, game_state)
325
+
|> assign(:invitations, %{})
326
+
|> assign(:sent_invitations, %{})
327
+
328
+
{:noreply, socket}
329
+
else
330
+
{:noreply, socket}
331
+
end
332
+
end
333
+
334
+
@impl true
335
+
def handle_info({:game_updated, game_state}, socket) do
336
+
# Only process game updates for the current game
337
+
if socket.assigns.game_state && game_state.id == socket.assigns.game_state.id do
338
+
socket = socket |> assign(:game_state, game_state)
339
+
{:noreply, socket}
340
+
else
341
+
{:noreply, socket}
342
+
end
343
+
end
344
+
345
+
@impl true
346
+
def handle_info(%Phoenix.Socket.Broadcast{event: "presence_diff", payload: diff}, socket) do
347
+
# Handle leaves first - remove from ETS
348
+
if map_size(diff.leaves) > 0 do
349
+
for {user_id, _} <- diff.leaves do
350
+
# Remove from ETS
351
+
remove_player_from_ets(user_id)
352
+
# Broadcast player left
353
+
Phoenix.PubSub.broadcast!(Blog.PubSub, @topic, {:player_left, user_id})
354
+
end
355
+
356
+
# Clean up invitations for players who left
357
+
socket = update_invitations_after_players_left(socket, Map.keys(diff.leaves))
358
+
end
359
+
360
+
# Handle joins - update ETS with new player data
361
+
if map_size(diff.joins) > 0 do
362
+
for {user_id, %{metas: [meta | _]}} <- diff.joins do
363
+
# Add to ETS
364
+
store_player_in_ets(user_id, meta)
365
+
end
366
+
end
367
+
368
+
# Update socket with fresh player list
369
+
all_players = get_all_players_from_ets()
370
+
socket = socket |> assign(:players, all_players)
371
+
372
+
# Also update game state if needed - if a player in the game left
373
+
socket = check_game_state_for_disconnects(socket, diff.leaves)
374
+
375
+
{:noreply, socket}
376
+
end
377
+
378
+
@impl true
379
+
def handle_info(:ping, socket) do
380
+
{:noreply, socket}
381
+
end
382
+
383
+
# Check if any player who left was in the current game
384
+
defp check_game_state_for_disconnects(socket, leaves) do
385
+
game_state = socket.assigns.game_state
386
+
387
+
if game_state do
388
+
player_left = Enum.any?(Map.keys(leaves), fn user_id ->
389
+
user_id == game_state.player1 || user_id == game_state.player2
390
+
end)
391
+
392
+
if player_left do
393
+
assign(socket, :game_state, nil)
394
+
else
395
+
socket
396
+
end
397
+
else
398
+
socket
399
+
end
400
+
end
401
+
402
+
# Remove invitations for players who left
403
+
defp update_invitations_after_players_left(socket, left_user_ids) do
404
+
# Remove invitations TO users who left
405
+
# Remove invitations FROM users who left
406
+
updated_invitations = socket.assigns.invitations
407
+
|> Map.drop(left_user_ids)
408
+
|> Enum.reject(fn {_, inv} -> Enum.member?(left_user_ids, inv.from) end)
409
+
|> Map.new()
410
+
411
+
# Also clean up sent invitations to users who left
412
+
updated_sent_invitations = socket.assigns.sent_invitations
413
+
|> Map.drop(left_user_ids)
414
+
415
+
socket
416
+
|> assign(:invitations, updated_invitations)
417
+
|> assign(:sent_invitations, updated_sent_invitations)
418
+
end
419
+
420
+
# Private functions
421
+
422
+
defp start_new_game(player1, player2) do
423
+
deck = create_deck() |> shuffle_deck()
424
+
{player1_cards, player2_cards} = deal_cards(deck)
425
+
426
+
%{
427
+
id: "game_#{:rand.uniform(1000000)}",
428
+
player1: player1,
429
+
player2: player2,
430
+
player1_cards: player1_cards,
431
+
player2_cards: player2_cards,
432
+
player1_card: nil,
433
+
player2_card: nil,
434
+
war_pile: [],
435
+
war_in_progress: false,
436
+
scoring_phase: false,
437
+
winner: nil,
438
+
status: "playing"
439
+
}
440
+
end
441
+
442
+
defp create_deck do
443
+
suits = ["hearts", "diamonds", "clubs", "spades"]
444
+
values = ["2", "3", "4", "5", "6", "7", "8", "9", "10", "jack", "queen", "king", "ace"]
445
+
446
+
for suit <- suits, value <- values do
447
+
%{
448
+
suit: suit,
449
+
value: value,
450
+
rank: card_rank(value)
451
+
}
452
+
end
453
+
end
454
+
455
+
defp card_rank(value) do
456
+
case value do
457
+
"2" -> 2
458
+
"3" -> 3
459
+
"4" -> 4
460
+
"5" -> 5
461
+
"6" -> 6
462
+
"7" -> 7
463
+
"8" -> 8
464
+
"9" -> 9
465
+
"10" -> 10
466
+
"jack" -> 11
467
+
"queen" -> 12
468
+
"king" -> 13
469
+
"ace" -> 14
470
+
end
471
+
end
472
+
473
+
defp shuffle_deck(deck) do
474
+
Enum.shuffle(deck)
475
+
end
476
+
477
+
defp deal_cards(deck) do
478
+
{player1_cards, player2_cards} = Enum.split(deck, div(length(deck), 2))
479
+
{player1_cards, player2_cards}
480
+
end
481
+
482
+
defp play_round(game, user_id) do
483
+
# If a war is in progress, explain that cards have already been played automatically
484
+
if game.war_in_progress do
485
+
game # Return game unchanged - war is handled automatically
486
+
else
487
+
# If we're in scoring phase, don't allow additional card plays
488
+
if game.scoring_phase do
489
+
game
490
+
else
491
+
# Only proceed if it's this player's turn or if waiting for both cards
492
+
if game.player1_card == nil || game.player2_card == nil do
493
+
cond do
494
+
user_id == game.player1 && game.player1_card == nil && length(game.player1_cards) > 0 ->
495
+
[card | rest] = game.player1_cards
496
+
game = %{game | player1_card: card, player1_cards: rest}
497
+
check_round_completion(game)
498
+
499
+
user_id == game.player2 && game.player2_card == nil && length(game.player2_cards) > 0 ->
500
+
[card | rest] = game.player2_cards
501
+
game = %{game | player2_card: card, player2_cards: rest}
502
+
check_round_completion(game)
503
+
504
+
true ->
505
+
game
506
+
end
507
+
else
508
+
game
509
+
end
510
+
end
511
+
end
512
+
end
513
+
514
+
defp check_round_completion(game) do
515
+
if game.player1_card != nil && game.player2_card != nil do
516
+
# When both cards are played, enter scoring phase rather than
517
+
# immediately resolving
518
+
%{game | scoring_phase: true}
519
+
else
520
+
game
521
+
end
522
+
end
523
+
524
+
defp resolve_round(game) do
525
+
player1_rank = game.player1_card.rank
526
+
player2_rank = game.player2_card.rank
527
+
528
+
war_pile = [game.player1_card, game.player2_card | game.war_pile]
529
+
530
+
cond do
531
+
# Player 1 wins the round
532
+
player1_rank > player2_rank ->
533
+
player1_cards = game.player1_cards ++ war_pile
534
+
%{game |
535
+
player1_cards: player1_cards,
536
+
player2_cards: game.player2_cards,
537
+
player1_card: nil,
538
+
player2_card: nil,
539
+
war_pile: [],
540
+
war_in_progress: false,
541
+
scoring_phase: false,
542
+
winner: check_for_winner(player1_cards, game.player2_cards)
543
+
}
544
+
545
+
# Player 2 wins the round
546
+
player2_rank > player1_rank ->
547
+
player2_cards = game.player2_cards ++ war_pile
548
+
%{game |
549
+
player1_cards: game.player1_cards,
550
+
player2_cards: player2_cards,
551
+
player1_card: nil,
552
+
player2_card: nil,
553
+
war_pile: [],
554
+
war_in_progress: false,
555
+
scoring_phase: false,
556
+
winner: check_for_winner(game.player1_cards, player2_cards)
557
+
}
558
+
559
+
# War! (equal cards)
560
+
true ->
561
+
handle_war(game, war_pile)
562
+
end
563
+
end
564
+
565
+
defp handle_war(game, war_pile) do
566
+
# Check if either player doesn't have enough cards for war
567
+
cond do
568
+
# If player 1 has fewer than 2 cards, they lose (need 1 face down, 1 face up)
569
+
length(game.player1_cards) < 2 ->
570
+
%{game |
571
+
player1_cards: [],
572
+
player2_cards: game.player2_cards ++ war_pile,
573
+
player1_card: nil,
574
+
player2_card: nil,
575
+
war_pile: [],
576
+
war_in_progress: false,
577
+
scoring_phase: false,
578
+
winner: "player2"
579
+
}
580
+
581
+
# If player 2 has fewer than 2 cards, they lose (need 1 face down, 1 face up)
582
+
length(game.player2_cards) < 2 ->
583
+
%{game |
584
+
player1_cards: game.player1_cards ++ war_pile,
585
+
player2_cards: [],
586
+
player1_card: nil,
587
+
player2_card: nil,
588
+
war_pile: [],
589
+
war_in_progress: false,
590
+
scoring_phase: false,
591
+
winner: "player1"
592
+
}
593
+
594
+
true ->
595
+
# Each player places one card face down
596
+
[face_down_1 | rest_1] = game.player1_cards
597
+
[face_down_2 | rest_2] = game.player2_cards
598
+
599
+
# Each player places one card face up (automated)
600
+
[face_up_1 | new_rest_1] = rest_1
601
+
[face_up_2 | new_rest_2] = rest_2
602
+
603
+
# Add face down cards to war pile
604
+
updated_war_pile = [face_down_1, face_down_2 | war_pile]
605
+
606
+
# Compare the face up cards
607
+
face_up_1_rank = face_up_1.rank
608
+
face_up_2_rank = face_up_2.rank
609
+
610
+
cond do
611
+
# Player 1 wins the war
612
+
face_up_1_rank > face_up_2_rank ->
613
+
# Player 1 gets all cards including face up cards
614
+
player1_cards = new_rest_1 ++ [face_up_1, face_up_2 | updated_war_pile]
615
+
%{game |
616
+
player1_cards: player1_cards,
617
+
player2_cards: new_rest_2,
618
+
player1_card: nil,
619
+
player2_card: nil,
620
+
war_pile: [],
621
+
war_in_progress: false,
622
+
scoring_phase: false,
623
+
winner: check_for_winner(player1_cards, new_rest_2)
624
+
}
625
+
626
+
# Player 2 wins the war
627
+
face_up_2_rank > face_up_1_rank ->
628
+
# Player 2 gets all cards including face up cards
629
+
player2_cards = new_rest_2 ++ [face_up_1, face_up_2 | updated_war_pile]
630
+
%{game |
631
+
player1_cards: new_rest_1,
632
+
player2_cards: player2_cards,
633
+
player1_card: nil,
634
+
player2_card: nil,
635
+
war_pile: [],
636
+
war_in_progress: false,
637
+
scoring_phase: false,
638
+
winner: check_for_winner(new_rest_1, player2_cards)
639
+
}
640
+
641
+
# Another war! (face up cards are equal)
642
+
true ->
643
+
# Add the face up cards to the war pile and continue
644
+
new_war_pile = [face_up_1, face_up_2 | updated_war_pile]
645
+
# Recursive call to handle the next war
646
+
handle_war(
647
+
%{game |
648
+
player1_cards: new_rest_1,
649
+
player2_cards: new_rest_2,
650
+
war_pile: new_war_pile,
651
+
war_in_progress: true
652
+
},
653
+
[]
654
+
)
655
+
end
656
+
end
657
+
end
658
+
659
+
defp check_for_winner(player1_cards, player2_cards) do
660
+
cond do
661
+
Enum.empty?(player1_cards) -> "player2"
662
+
Enum.empty?(player2_cards) -> "player1"
663
+
true -> nil
664
+
end
665
+
end
666
+
end
+287
lib/blog_web/live/war_live.html.heex
+287
lib/blog_web/live/war_live.html.heex
···
1
+
<div class="mx-auto max-w-6xl px-4 py-8">
2
+
<h1 class="mb-8 text-3xl font-bold text-center">War Card Game</h1>
3
+
4
+
<div class="mb-6 bg-slate-100 rounded-lg p-4 shadow-md">
5
+
<div class="bg-blue-100 border border-blue-300 p-3 rounded-md mb-4 text-blue-800">
6
+
<div class="flex items-center justify-between">
7
+
<div>
8
+
<p><strong>Test Mode:</strong> Open this page in multiple tabs to play against yourself.</p>
9
+
<p class="text-xs text-blue-600 font-mono mt-1">ID: <%= @user_id %></p>
10
+
</div>
11
+
<div class="flex items-center">
12
+
<%= if @edit_name do %>
13
+
<form phx-submit="save_display_name" phx-change="change_name_form" class="flex items-center">
14
+
<input type="text" name="display_name" value={@name_form["display_name"]}
15
+
class="px-2 py-1 border border-blue-400 rounded mr-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
16
+
placeholder="Enter name"
17
+
autofocus
18
+
maxlength="20" />
19
+
<button type="submit" class="bg-blue-500 text-white px-3 py-1 rounded hover:bg-blue-600 transition-colors duration-200">
20
+
Save
21
+
</button>
22
+
</form>
23
+
<% else %>
24
+
<div class="flex items-center mr-2">
25
+
<span class="font-semibold mr-2">Your name:</span>
26
+
<span class="text-blue-700 font-medium"><%= player_display_name(@players[@user_id]) %></span>
27
+
</div>
28
+
<button phx-click="toggle_edit_name" class="text-sm bg-blue-200 text-blue-800 px-2 py-1 rounded hover:bg-blue-300 transition-colors duration-200">
29
+
Change Name
30
+
</button>
31
+
<% end %>
32
+
</div>
33
+
</div>
34
+
</div>
35
+
36
+
<%= if @game_state do %>
37
+
<div class="p-4 mb-4 bg-white rounded-lg shadow-inner">
38
+
<div class="flex justify-between items-center mb-4">
39
+
<div class="text-lg">
40
+
<span class="font-bold">Player 1:</span>
41
+
<span class={if @game_state.player1 == @user_id, do: "text-blue-600 font-bold", else: ""}>
42
+
<%= player_display_name(@players[@game_state.player1]) %>
43
+
</span>
44
+
<span class="ml-2 text-sm">(<%= length(@game_state.player1_cards) %> cards)</span>
45
+
<%= if @game_state.player1 == @user_id do %>
46
+
<span class="ml-1 text-xs font-semibold bg-blue-100 px-1 py-0.5 rounded-full text-blue-800">YOU</span>
47
+
<% end %>
48
+
</div>
49
+
<div class="text-lg">
50
+
<span class="font-bold">Player 2:</span>
51
+
<span class={if @game_state.player2 == @user_id, do: "text-blue-600 font-bold", else: ""}>
52
+
<%= player_display_name(@players[@game_state.player2]) %>
53
+
</span>
54
+
<span class="ml-2 text-sm">(<%= length(@game_state.player2_cards) %> cards)</span>
55
+
<%= if @game_state.player2 == @user_id do %>
56
+
<span class="ml-1 text-xs font-semibold bg-blue-100 px-1 py-0.5 rounded-full text-blue-800">YOU</span>
57
+
<% end %>
58
+
</div>
59
+
</div>
60
+
61
+
<div class="flex justify-center mb-6">
62
+
<%= if @game_state.winner do %>
63
+
<div class="text-center bg-yellow-100 p-4 rounded-lg w-full">
64
+
<h3 class="text-2xl font-bold mb-2">
65
+
Game Over!
66
+
</h3>
67
+
<p class="text-xl">
68
+
<%= if @game_state.winner == "player1" do %>
69
+
<%= player_display_name(@players[@game_state.player1]) %> wins!
70
+
<% else %>
71
+
<%= player_display_name(@players[@game_state.player2]) %> wins!
72
+
<% end %>
73
+
</p>
74
+
</div>
75
+
<% else %>
76
+
<div class="text-center mb-4">
77
+
<%= if @game_state.war_in_progress do %>
78
+
<div class="bg-red-100 p-4 rounded-lg mb-2 text-red-700 font-bold text-xl">
79
+
WAR!
80
+
</div>
81
+
<p>Each player has placed one card face down and one card face up</p>
82
+
<div class="text-sm mt-2">
83
+
<%= length(@game_state.war_pile) %> cards in war pile
84
+
</div>
85
+
<div class="text-sm italic mt-2 text-gray-600">
86
+
In a war, each player automatically puts one card face down and one card face up.
87
+
The player with the higher face up card wins all cards. If they tie again, another war is triggered.
88
+
</div>
89
+
<% end %>
90
+
91
+
<%= if @game_state.scoring_phase do %>
92
+
<div class="mt-4">
93
+
<div class="mb-2">
94
+
<%= cond do %>
95
+
<% @game_state.player1_card && @game_state.player2_card && @game_state.player1_card.rank > @game_state.player2_card.rank -> %>
96
+
<div class="text-green-600 font-semibold">
97
+
<%= player_display_name(@players[@game_state.player1]) %> wins this round!
98
+
</div>
99
+
<% @game_state.player1_card && @game_state.player2_card && @game_state.player2_card.rank > @game_state.player1_card.rank -> %>
100
+
<div class="text-green-600 font-semibold">
101
+
<%= player_display_name(@players[@game_state.player2]) %> wins this round!
102
+
</div>
103
+
<% @game_state.player1_card && @game_state.player2_card -> %>
104
+
<div class="text-red-600 font-semibold">
105
+
Cards are equal! War is triggered!
106
+
</div>
107
+
<% true -> %>
108
+
<div class="text-gray-600 font-semibold">
109
+
Resolving round...
110
+
</div>
111
+
<% end %>
112
+
</div>
113
+
<button phx-click="continue_round" class="mt-2 px-4 py-2 bg-blue-500 hover:bg-blue-600 text-white rounded-md transition-colors duration-200">
114
+
Continue
115
+
</button>
116
+
</div>
117
+
<% end %>
118
+
</div>
119
+
<% end %>
120
+
</div>
121
+
122
+
<div class="flex justify-center items-center gap-8 mb-6">
123
+
<!-- Player 1's card -->
124
+
<div class="w-32 h-44 rounded-lg relative">
125
+
<%= if @game_state.player1_card do %>
126
+
<div class="absolute inset-0 bg-white border-2 border-gray-300 rounded-lg shadow-lg flex flex-col justify-center items-center p-2">
127
+
<div class={["text-3xl font-bold", card_color(@game_state.player1_card.suit)]}>
128
+
<%= display_card_value(@game_state.player1_card.value) %>
129
+
</div>
130
+
<div class={["text-4xl mt-2", card_color(@game_state.player1_card.suit)]}>
131
+
<%= display_card_suit(@game_state.player1_card.suit) %>
132
+
</div>
133
+
</div>
134
+
<% else %>
135
+
<%= if !@game_state.winner && !@game_state.scoring_phase && @game_state.player1 == @user_id && length(@game_state.player1_cards) > 0 do %>
136
+
<button phx-click="play_card" class="absolute inset-0 bg-blue-500 hover:bg-blue-600 text-white rounded-lg shadow-lg flex flex-col justify-center items-center transition-colors duration-200">
137
+
<div class="text-xl font-bold">Play Card</div>
138
+
</button>
139
+
<% else %>
140
+
<div class="absolute inset-0 bg-gray-200 border-2 border-gray-300 rounded-lg flex justify-center items-center">
141
+
<span class="text-gray-500 text-lg">
142
+
<%= cond do %>
143
+
<% @game_state.winner -> %>
144
+
Game Over
145
+
<% @game_state.scoring_phase -> %>
146
+
Awaiting Continue
147
+
<% true -> %>
148
+
Waiting...
149
+
<% end %>
150
+
</span>
151
+
</div>
152
+
<% end %>
153
+
<% end %>
154
+
</div>
155
+
156
+
<!-- VS -->
157
+
<div class="text-2xl font-bold text-gray-600">VS</div>
158
+
159
+
<!-- Player 2's card -->
160
+
<div class="w-32 h-44 rounded-lg relative">
161
+
<%= if @game_state.player2_card do %>
162
+
<div class="absolute inset-0 bg-white border-2 border-gray-300 rounded-lg shadow-lg flex flex-col justify-center items-center p-2">
163
+
<div class={["text-3xl font-bold", card_color(@game_state.player2_card.suit)]}>
164
+
<%= display_card_value(@game_state.player2_card.value) %>
165
+
</div>
166
+
<div class={["text-4xl mt-2", card_color(@game_state.player2_card.suit)]}>
167
+
<%= display_card_suit(@game_state.player2_card.suit) %>
168
+
</div>
169
+
</div>
170
+
<% else %>
171
+
<%= if !@game_state.winner && !@game_state.scoring_phase && @game_state.player2 == @user_id && length(@game_state.player2_cards) > 0 do %>
172
+
<button phx-click="play_card" class="absolute inset-0 bg-blue-500 hover:bg-blue-600 text-white rounded-lg shadow-lg flex flex-col justify-center items-center transition-colors duration-200">
173
+
<div class="text-xl font-bold">Play Card</div>
174
+
</button>
175
+
<% else %>
176
+
<div class="absolute inset-0 bg-gray-200 border-2 border-gray-300 rounded-lg flex justify-center items-center">
177
+
<span class="text-gray-500 text-lg">
178
+
<%= cond do %>
179
+
<% @game_state.winner -> %>
180
+
Game Over
181
+
<% @game_state.scoring_phase -> %>
182
+
Awaiting Continue
183
+
<% true -> %>
184
+
Waiting...
185
+
<% end %>
186
+
</span>
187
+
</div>
188
+
<% end %>
189
+
<% end %>
190
+
</div>
191
+
</div>
192
+
193
+
<div class="flex justify-between">
194
+
<!-- Player 1's deck -->
195
+
<div class="w-24 h-32 relative">
196
+
<%= if length(@game_state.player1_cards) > 0 do %>
197
+
<div class="absolute inset-0 bg-blue-700 rounded-lg shadow-md flex justify-center items-center">
198
+
<div class="text-white text-sm font-bold">
199
+
<%= length(@game_state.player1_cards) %> cards
200
+
</div>
201
+
</div>
202
+
<% else %>
203
+
<div class="absolute inset-0 border-2 border-dashed border-gray-300 rounded-lg flex justify-center items-center">
204
+
<div class="text-gray-400 text-sm">Empty</div>
205
+
</div>
206
+
<% end %>
207
+
</div>
208
+
209
+
<!-- Player 2's deck -->
210
+
<div class="w-24 h-32 relative">
211
+
<%= if length(@game_state.player2_cards) > 0 do %>
212
+
<div class="absolute inset-0 bg-blue-700 rounded-lg shadow-md flex justify-center items-center">
213
+
<div class="text-white text-sm font-bold">
214
+
<%= length(@game_state.player2_cards) %> cards
215
+
</div>
216
+
</div>
217
+
<% else %>
218
+
<div class="absolute inset-0 border-2 border-dashed border-gray-300 rounded-lg flex justify-center items-center">
219
+
<div class="text-gray-400 text-sm">Empty</div>
220
+
</div>
221
+
<% end %>
222
+
</div>
223
+
</div>
224
+
</div>
225
+
<% else %>
226
+
<!-- Lobby -->
227
+
<div class="bg-white p-4 rounded-lg">
228
+
<h3 class="text-xl font-bold mb-4">Lobby</h3>
229
+
230
+
<!-- Incoming invitations -->
231
+
<%= if map_size(@invitations) > 0 do %>
232
+
<div class="mb-4">
233
+
<h4 class="font-bold text-lg mb-2">Invitations:</h4>
234
+
<div class="space-y-2">
235
+
<%= for {from_id, invitation} <- @invitations do %>
236
+
<div class="flex items-center justify-between p-3 bg-yellow-50 rounded-lg border border-yellow-200">
237
+
<div>
238
+
<span class="font-medium"><%= player_display_name(@players[from_id]) %></span> has invited you to play
239
+
</div>
240
+
<div class="flex space-x-2">
241
+
<button phx-click="accept_invitation" phx-value-from={invitation.from} class="px-3 py-1 bg-green-500 hover:bg-green-600 text-white rounded transition-colors duration-200">
242
+
Accept
243
+
</button>
244
+
<button phx-click="decline_invitation" phx-value-from={invitation.from} class="px-3 py-1 bg-red-500 hover:bg-red-600 text-white rounded transition-colors duration-200">
245
+
Decline
246
+
</button>
247
+
</div>
248
+
</div>
249
+
<% end %>
250
+
</div>
251
+
</div>
252
+
<% end %>
253
+
254
+
<h4 class="font-bold text-lg mb-2">Players Online:</h4>
255
+
<%= if map_size(@players) > 1 do %>
256
+
<div class="space-y-2">
257
+
<%= for {player_id, meta} <- @players do %>
258
+
<%= if player_id != @user_id do %>
259
+
<div class="flex items-center justify-between p-3 bg-slate-50 rounded-lg border border-slate-200">
260
+
<div>
261
+
<span class="font-medium"><%= player_display_name(meta) %></span>
262
+
<span class="text-xs text-gray-500 ml-2">
263
+
online for <%= time_ago(meta.online_at) %>
264
+
</span>
265
+
</div>
266
+
<%= if Map.has_key?(@sent_invitations, player_id) do %>
267
+
<div class="text-orange-500 text-sm italic">
268
+
Invitation sent
269
+
</div>
270
+
<% else %>
271
+
<button phx-click="invite_player" phx-value-id={player_id} class="px-3 py-1 bg-blue-500 hover:bg-blue-600 text-white rounded transition-colors duration-200">
272
+
Invite to Play
273
+
</button>
274
+
<% end %>
275
+
</div>
276
+
<% end %>
277
+
<% end %>
278
+
</div>
279
+
<% else %>
280
+
<div class="p-4 text-center text-gray-500 italic">
281
+
Waiting for other players to join...
282
+
</div>
283
+
<% end %>
284
+
</div>
285
+
<% end %>
286
+
</div>
287
+
</div>