this repo has no description

implement no JS war card game, claude 3.7 sonnet thinking, one shot with light follow up

Changed files
+963 -8
lib
+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
··· 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
··· 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
··· 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>
+1
lib/blog_web/router.ex
··· 41 41 live "/generative-art", GenerativeArtLive, :index 42 42 # live "/breakout", BreakoutLive, :index 43 43 live "/blackjack", BlackjackLive, :index 44 + live "/war", WarLive, :index 44 45 end 45 46 46 47 # Other scopes may use custom stacks.