diff --git a/TODO.md b/TODO.md index 9ec9ad2..17b93bd 100644 --- a/TODO.md +++ b/TODO.md @@ -2,7 +2,6 @@ - CD: https://fly.io/docs/app-guides/continuous-deployment-with-github-actions/ - Put everyone's discards in front of their hand (suggested by Mom) -- If no undoable moves, Undo button should reset the game to get first discarded tile of the game from bot (human went last, missed a discarded tile, can't go back to beginning of game). - Bug: Undoing a win after a bot drew a tile hung bot (workaround: paused & unpaused bots) - Shrink tile sizes on mobile - Lobby not receiving bot player added diff --git a/lib/mjw/core/game.ex b/lib/mjw/core/game.ex index 58fbf8a..278e332 100644 --- a/lib/mjw/core/game.ex +++ b/lib/mjw/core/game.ex @@ -43,7 +43,7 @@ defmodule Mjw.Game do turn_seatno: 0, # Where the deal picking started from. Might be used to count points. dealpick_seatno: 0, - # Number of times the player has been dealer (wins, draws, DQs all count) + # Number of times the player has been dealer (draws, etc, also count) dealer_win_count: 0, event_log: [], undo_seatno: nil, @@ -51,17 +51,10 @@ defmodule Mjw.Game do pause_bots: false @doc """ - Initialize a game with a random ID and a shuffled deck + Initialize a game, defaulting to a random ID and a shuffled deck """ - def new() do - new(Uniq.UUID.uuid4()) - end - - defp new(id) do - %__MODULE__{ - id: id, - deck: shuffled_deck() - } + def new(id \\ Uniq.UUID.uuid4()) do + %__MODULE__{id: id, deck: shuffled_deck()} end defp shuffled_deck() do @@ -193,7 +186,8 @@ defmodule Mjw.Game do @doc """ Roll dice and deal the deck. The dealer will have 14 tiles and others will - have 13. Set dealpick_seatno and change turn_state to discarding. + have 13. Sets dealpick_seatno, changes turn_state to discarding, and sets + undo_state to an initial value. """ def roll_dice_and_deal(%__MODULE__{turn_state: :rolling} = game) do game @@ -387,6 +381,7 @@ defmodule Mjw.Game do tile = Mjw.BotStrategy.discard(game) game + |> set_undo_state_if_first_discard() |> log_discard_event(seatno, tile) |> update_seat(seatno, &Mjw.Seat.remove_from_concealed(&1, tile)) |> Map.merge(%{discards: [tile | game.discards], turn_state: :drawing}) @@ -722,9 +717,18 @@ defmodule Mjw.Game do update_seat(game, seatno, fn _seat_being_replaced -> seat end) end - def undo(%__MODULE__{undo_seatno: undo_seatno, undo_state: %__MODULE__{} = undo_state} = game) - when undo_seatno != nil do - player_name = player_name_at(game, undo_seatno) + def undo( + %__MODULE__{undo_seatno: undo_seatno, undo_state: %__MODULE__{} = undo_state} = game, + player_seatno + ) do + player_name = player_name_at(game, player_seatno) + + log_event_message = + if undo_seatno do + "#{player_name} undid their action." + else + "#{player_name} reset the game." + end undo_state # event_log is preserved as a singleton at the top-level game @@ -732,7 +736,7 @@ defmodule Mjw.Game do |> merge_seats_for_undo(game) # just in case undoing a declared win |> clear_all_seat_win_attributes() - |> log_event("#{player_name} undid their action.") + |> log_event(log_event_message) end # The undo player's hand will change after an undo, but try to preserve their @@ -764,6 +768,15 @@ defmodule Mjw.Game do end) end + @doc """ + A player can undo when they performed the last human action, OR if the + undo_seatno is nil which indicates it's the beginning of the game and we can + undo the initial bot discards + """ + def can_undo?(%__MODULE__{undo_seatno: undo_seatno, undo_state: undo_state}, seatno) do + undo_state && (!undo_seatno || undo_seatno == seatno) + end + @doc """ Draw a tile from the deck, and temporarily hold it in the seat's peektile before deciding whether to keep or discard. Params enforce that this player @@ -960,11 +973,21 @@ defmodule Mjw.Game do # When an undoable change is made, set the undo_state so that the player in # undo_seatno can possibly undo it. event_log is not saved in undo states # because it exists as a singleton at the top-level game. - defp set_undo_state(%__MODULE__{} = game, undo_seatno) do + defp set_undo_state(%__MODULE__{} = game, undo_seatno \\ nil) do undo_state = game |> Map.delete(:event_log) %{game | undo_seatno: undo_seatno, undo_state: undo_state} end + # When a bot discards first, set the initial undo_state so a human can Undo + # to the beginning of the game to pick up a bot's discard they missed + defp set_undo_state_if_first_discard(%__MODULE__{} = game) do + if Enum.empty?(game.discards) do + set_undo_state(game) + else + game + end + end + def seat_bot(%__MODULE__{} = game) do empty_seatno = Enum.find_index(game.seats, &Mjw.Seat.empty?/1) diff --git a/lib/mjw/core/seat.ex b/lib/mjw/core/seat.ex index bdbc4e0..c6d3908 100644 --- a/lib/mjw/core/seat.ex +++ b/lib/mjw/core/seat.ex @@ -26,9 +26,8 @@ defmodule Mjw.Seat do end @bot_id "bot" - def seat_bot(%__MODULE__{} = seat, bot_name) do - %{seat | player_id: @bot_id, player_name: bot_name} + seat_player(seat, @bot_id, bot_name) end def bot?(%__MODULE__{player_id: @bot_id}), do: true diff --git a/lib/mjw_web/live/game_live/show.ex b/lib/mjw_web/live/game_live/show.ex index 3332b96..402b263 100644 --- a/lib/mjw_web/live/game_live/show.ex +++ b/lib/mjw_web/live/game_live/show.ex @@ -132,9 +132,8 @@ defmodule MjwWeb.GameLive.Show do when socket.assigns.current_user_can_undo do game = socket.assigns.game - |> Mjw.Game.undo() - |> optionally_enqueue_bot_roll(socket) - |> optionally_enqueue_bot_draw(socket) + |> Mjw.Game.undo(socket.assigns.current_user_seatno) + |> optionally_enqueue_all_bot_actions socket = update_game(socket, game, :undo) @@ -912,7 +911,7 @@ defmodule MjwWeb.GameLive.Show do |> assign(:deck_remaining, length(game.deck)) |> assign(:empty_seats_count, Mjw.Game.empty_seats_count(game)) |> assign(:turn_player_name, Mjw.Game.turn_player_name(game)) - |> assign(:current_user_can_undo, game.undo_seatno == current_user_seatno) + |> assign(:current_user_can_undo, Mjw.Game.can_undo?(game, current_user_seatno)) |> assign(:bots_present, Mjw.Game.bots_present?(game)) end diff --git a/test/mjw/core/game_test.exs b/test/mjw/core/game_test.exs index fc94c13..5e0d240 100644 --- a/test/mjw/core/game_test.exs +++ b/test/mjw/core/game_test.exs @@ -1396,7 +1396,7 @@ defmodule Mjw.GameTest do } {:ok, game} = Mjw.Game.discard(orig_game, 3, "n3-3") - game = Mjw.Game.undo(game) + game = Mjw.Game.undo(game, 3) expected_event_log = [{"name3 undid their action.", nil}, {"name3 discarded.", "n3-3"}] assert game == %{orig_game | event_log: expected_event_log} @@ -1423,7 +1423,7 @@ defmodule Mjw.GameTest do game = orig_game |> Mjw.Game.draw_discard(3, ["dp-0"], "dp-0") - |> Mjw.Game.undo() + |> Mjw.Game.undo(3) expected_event_log = [ {"name3 undid their action.", nil}, @@ -1454,7 +1454,7 @@ defmodule Mjw.GameTest do game = orig_game |> Mjw.Game.pong(1, ["dp-0"], "dp-0") - |> Mjw.Game.undo() + |> Mjw.Game.undo(1) expected_event_log = [ {"name1 undid their action.", nil}, @@ -1487,7 +1487,7 @@ defmodule Mjw.GameTest do orig_game |> Mjw.Game.peek_deck_tile(3) |> Mjw.Game.clear_peektile(3) - |> Mjw.Game.undo() + |> Mjw.Game.undo(3) expected_event_log = [ {"name3 undid their action.", nil}, @@ -1519,7 +1519,7 @@ defmodule Mjw.GameTest do {game, "b1-0"} = orig_game |> Mjw.Game.draw_correction_tile(3, ["n1-3", "n2-3", "n3-3", "decktile"]) - game = game |> Mjw.Game.undo() + game = game |> Mjw.Game.undo(3) expected_event_log = [ {"name3 undid their action.", nil}, @@ -1550,7 +1550,7 @@ defmodule Mjw.GameTest do game = orig_game |> Mjw.Game.declare_win_from_discards(1, "dp-0") - |> Mjw.Game.undo() + |> Mjw.Game.undo(1) expected_event_log = [{"name1 undid their action.", nil}, {"name1 went out!", "dp-0"}] assert game == %{orig_game | event_log: expected_event_log} @@ -1577,7 +1577,7 @@ defmodule Mjw.GameTest do game = orig_game |> Mjw.Game.declare_win_from_hand(1, "n3-1") - |> Mjw.Game.undo() + |> Mjw.Game.undo(1) expected_event_log = [{"name1 undid their action.", nil}, {"name1 went out!", "n3-1"}] assert game == %{orig_game | event_log: expected_event_log} @@ -1615,7 +1615,7 @@ defmodule Mjw.GameTest do {:ok, game} = Mjw.Game.discard(orig_game, 0, "b5-0") {:draw_discard, game} = Mjw.Game.bot_draw(game) - game = Mjw.Game.undo(game) + game = Mjw.Game.undo(game, 0) expected_event_log = [ {"name0 undid their action.", nil}, @@ -2039,4 +2039,71 @@ defmodule Mjw.GameTest do assert game.turn_state == :discarding end end + + describe "can_undo?" do + test "is false for all players before the first discard" do + game = + Mjw.Game.new() + |> Mjw.Game.seat_player("id0", "name0") + |> Mjw.Game.seat_player("id1", "name1") + |> Mjw.Game.seat_player("id2", "name2") + |> Mjw.Game.seat_bot() + |> Mjw.Game.pick_random_available_wind(0) + |> Mjw.Game.pick_random_available_wind(1) + |> Mjw.Game.pick_random_available_wind(2) + |> Mjw.Game.pick_random_available_wind(3) + |> Mjw.Game.roll_dice_and_reseat_players() + |> Mjw.Game.roll_dice_and_deal() + + refute Mjw.Game.can_undo?(game, 0) + refute Mjw.Game.can_undo?(game, 1) + refute Mjw.Game.can_undo?(game, 2) + refute Mjw.Game.can_undo?(game, 3) + end + + test "is true for all human players after the first bot discard" do + {:ok, game} = + Mjw.Game.new() + |> Mjw.Game.seat_player("id0", "name0") + |> Mjw.Game.seat_player("id1", "name1") + |> Mjw.Game.seat_player("id2", "name2") + |> Mjw.Game.seat_bot() + |> Mjw.Game.pick_random_available_wind(0) + |> Mjw.Game.pick_random_available_wind(1) + |> Mjw.Game.pick_random_available_wind(2) + |> Mjw.Game.pick_random_available_wind(3) + |> Mjw.Game.roll_dice_and_reseat_players() + |> Mjw.Game.roll_dice_and_deal() + |> Map.merge(%{turn_seatno: 3}) + |> Mjw.Game.bot_discard() + + assert Mjw.Game.can_undo?(game, 0) + assert Mjw.Game.can_undo?(game, 1) + assert Mjw.Game.can_undo?(game, 2) + end + + test "when undo_seatno is set, is true only for the undo_seatno player id" do + orig_game = + Mjw.Game.new() + |> Mjw.Game.seat_player("id0", "name0") + |> Mjw.Game.seat_player("id1", "name1") + |> Mjw.Game.seat_player("id2", "name2") + |> Mjw.Game.seat_bot() + |> Mjw.Game.pick_random_available_wind(0) + |> Mjw.Game.pick_random_available_wind(1) + |> Mjw.Game.pick_random_available_wind(2) + |> Mjw.Game.pick_random_available_wind(3) + |> Mjw.Game.roll_dice_and_reseat_players() + |> Mjw.Game.roll_dice_and_deal() + |> Map.merge(%{turn_seatno: 0}) + + tile = orig_game.seats |> Enum.at(0) |> Map.get(:concealed) |> Enum.random() + {:ok, game} = orig_game |> Mjw.Game.discard(0, tile) + + assert Mjw.Game.can_undo?(game, 0) + refute Mjw.Game.can_undo?(game, 1) + refute Mjw.Game.can_undo?(game, 2) + refute Mjw.Game.can_undo?(game, 3) + end + end end