defmodule Odinsea.Game.MiniGame do @moduledoc """ Mini Game system for Omok and Match Card games in player shops. Ported from src/server/shops/MapleMiniGame.java Mini games allow players to: - Play Omok (5-in-a-row) - Play Match Card (memory game) - Track wins/losses/ties - Earn game points Game Types: - 1 = Omok (5-in-a-row) - 2 = Match Card (memory matching) Game lifecycle: 1. Owner creates game with type and description 2. Visitor joins and both mark ready 3. Game starts and players take turns 4. Game ends with win/loss/tie """ use GenServer require Logger # Game type constants @game_type_omok 1 @game_type_match_card 2 # Shop type constants (from IMaplePlayerShop) @shop_type_omok 3 @shop_type_match_card 4 # Board size for Omok @omok_board_size 15 # Default slots for mini games @max_slots 2 # Struct for the mini game state defstruct [ :id, :owner_id, :owner_name, :item_id, :description, :password, :game_type, :piece_type, :map_id, :channel, :visitors, :ready, :points, :exit_after, :open, :available, # Omok specific :board, :loser, :turn, # Match card specific :match_cards, :first_slot, :tie_requested ] @doc """ Starts a new mini game GenServer. """ def start_link(opts) do game_id = Keyword.fetch!(opts, :id) GenServer.start_link(__MODULE__, opts, name: via_tuple(game_id)) end @doc """ Creates a new mini game. """ def create(opts) do game_type = opts[:game_type] || @game_type_omok %__MODULE__{ id: opts[:id] || generate_id(), owner_id: opts[:owner_id], owner_name: opts[:owner_name], item_id: opts[:item_id], description: opts[:description] || "", password: opts[:password] || "", game_type: game_type, piece_type: opts[:piece_type] || 0, map_id: opts[:map_id], channel: opts[:channel], visitors: %{}, ready: {false, false}, points: {0, 0}, exit_after: {false, false}, open: true, available: true, # Omok board (15x15 grid) board: create_empty_board(), loser: 0, turn: 1, # Match card match_cards: [], first_slot: 0, tie_requested: -1 } end @doc """ Returns the shop type for this game. """ def shop_type(%__MODULE__{game_type: type}) do case type do @game_type_omok -> @shop_type_omok @game_type_match_card -> @shop_type_match_card _ -> @shop_type_omok end end @doc """ Returns the game type constant. """ def game_type_omok, do: @game_type_omok def game_type_match_card, do: @game_type_match_card @doc """ Gets the current game state. """ def get_state(game_pid) when is_pid(game_pid) do GenServer.call(game_pid, :get_state) end def get_state(game_id) do case lookup(game_id) do {:ok, pid} -> get_state(pid) error -> error end end @doc """ Looks up a game by ID. """ def lookup(game_id) do case Registry.lookup(Odinsea.MiniGameRegistry, game_id) do [{pid, _}] -> {:ok, pid} [] -> {:error, :not_found} end end @doc """ Adds a visitor to the game. Returns the visitor slot or {:error, reason}. """ def add_visitor(game_id, character_id, character_pid) do with {:ok, pid} <- lookup(game_id) do GenServer.call(pid, {:add_visitor, character_id, character_pid}) end end @doc """ Removes a visitor from the game. """ def remove_visitor(game_id, character_id) do with {:ok, pid} <- lookup(game_id) do GenServer.call(pid, {:remove_visitor, character_id}) end end @doc """ Sets a player as ready/not ready. """ def set_ready(game_id, character_id) do with {:ok, pid} <- lookup(game_id) do GenServer.call(pid, {:set_ready, character_id}) end end @doc """ Checks if a player is ready. """ def is_ready?(game_id, character_id) do with {:ok, pid} <- lookup(game_id) do GenServer.call(pid, {:is_ready, character_id}) end end @doc """ Starts the game (if all players ready). """ def start_game(game_id) do with {:ok, pid} <- lookup(game_id) do GenServer.call(pid, :start_game) end end @doc """ Makes an Omok move. """ def make_omok_move(game_id, character_id, x, y, piece_type) do with {:ok, pid} <- lookup(game_id) do GenServer.call(pid, {:omok_move, character_id, x, y, piece_type}) end end @doc """ Selects a card in Match Card game. """ def select_card(game_id, character_id, slot) do with {:ok, pid} <- lookup(game_id) do GenServer.call(pid, {:select_card, character_id, slot}) end end @doc """ Requests a tie. """ def request_tie(game_id, character_id) do with {:ok, pid} <- lookup(game_id) do GenServer.call(pid, {:request_tie, character_id}) end end @doc """ Answers a tie request. """ def answer_tie(game_id, character_id, accept) do with {:ok, pid} <- lookup(game_id) do GenServer.call(pid, {:answer_tie, character_id, accept}) end end @doc """ Skips turn (forfeits move). """ def skip_turn(game_id, character_id) do with {:ok, pid} <- lookup(game_id) do GenServer.call(pid, {:skip_turn, character_id}) end end @doc """ Gives up (forfeits game). """ def give_up(game_id, character_id) do with {:ok, pid} <- lookup(game_id) do GenServer.call(pid, {:give_up, character_id}) end end @doc """ Sets exit after game flag. """ def set_exit_after(game_id, character_id) do with {:ok, pid} <- lookup(game_id) do GenServer.call(pid, {:set_exit_after, character_id}) end end @doc """ Checks if player wants to exit after game. """ def is_exit_after?(game_id, character_id) do with {:ok, pid} <- lookup(game_id) do GenServer.call(pid, {:is_exit_after, character_id}) end end @doc """ Gets the visitor slot for a character. """ def get_visitor_slot(game_id, character_id) do with {:ok, pid} <- lookup(game_id) do GenServer.call(pid, {:get_visitor_slot, character_id}) end end @doc """ Checks if character is the owner. """ def is_owner?(game_id, character_id) do with {:ok, pid} <- lookup(game_id) do GenServer.call(pid, {:is_owner, character_id}) end end @doc """ Closes the game. """ def close_game(game_id) do with {:ok, pid} <- lookup(game_id) do GenServer.call(pid, :close_game) end end @doc """ Gets the number of matches needed to win. """ def get_matches_to_win(piece_type) do case piece_type do 0 -> 6 1 -> 10 2 -> 15 _ -> 6 end end # ============================================================================ # GenServer Callbacks # ============================================================================ @impl true def init(opts) do state = create(opts) {:ok, state} end @impl true def handle_call(:get_state, _from, state) do {:reply, state, state} end @impl true def handle_call({:add_visitor, character_id, character_pid}, _from, state) do visitor_count = map_size(state.visitors) if visitor_count >= @max_slots - 1 do {:reply, {:error, :full}, state} else slot = visitor_count + 1 new_visitors = Map.put(state.visitors, character_id, %{pid: character_pid, slot: slot}) {:reply, {:ok, slot}, %{state | visitors: new_visitors}} end end @impl true def handle_call({:remove_visitor, character_id}, _from, state) do new_visitors = Map.delete(state.visitors, character_id) {:reply, :ok, %{state | visitors: new_visitors}} end @impl true def handle_call({:set_ready, character_id}, _from, state) do slot = get_slot_for_character_internal(state, character_id) if slot > 0 do {r0, r1} = state.ready new_ready = if slot == 1, do: {not r0, r1}, else: {r0, not r1} {:reply, :ok, %{state | ready: new_ready}} else {:reply, {:error, :not_visitor}, state} end end @impl true def handle_call({:is_ready, character_id}, _from, state) do slot = get_slot_for_character_internal(state, character_id) {r0, r1} = state.ready ready = if slot == 1, do: r0, else: r1 {:reply, ready, state} end @impl true def handle_call(:start_game, _from, state) do {r0, r1} = state.ready if r0 and r1 do # Initialize game based on type new_state = case state.game_type do @game_type_omok -> %{state | board: create_empty_board(), open: false} @game_type_match_card -> cards = generate_match_cards(state.piece_type) %{state | match_cards: cards, open: false} _ -> %{state | open: false} end {:reply, {:ok, new_state.loser}, new_state} else {:reply, {:error, :not_ready}, state} end end @impl true def handle_call({:omok_move, character_id, x, y, piece_type}, _from, state) do # Check if it's this player's turn (loser goes first) slot = get_slot_for_character_internal(state, character_id) if slot != state.loser + 1 do {:reply, {:error, :not_your_turn}, state} else # Check if position is valid and empty if x < 0 or x >= @omok_board_size or y < 0 or y >= @omok_board_size do {:reply, {:error, :invalid_position}, state} else current_piece = get_board_piece(state.board, x, y) if current_piece != 0 do {:reply, {:error, :position_occupied}, state} else # Place piece new_board = set_board_piece(state.board, x, y, piece_type) # Check for win won = check_omok_win(new_board, x, y, piece_type) # Next turn next_loser = rem(state.loser + 1, @max_slots) new_state = %{ state | board: new_board, loser: next_loser } if won do # Award point {p0, p1} = state.points new_points = if slot == 1, do: {p0 + 1, p1}, else: {p0, p1 + 1} {:reply, {:win, slot}, %{new_state | points: new_points, open: true}} else {:reply, {:ok, won}, new_state} end end end end end @impl true def handle_call({:select_card, character_id, slot}, _from, state) do # Match card logic slot = get_slot_for_character_internal(state, character_id) if slot != state.loser + 1 do {:reply, {:error, :not_your_turn}, state} else # Simplified match card logic # In full implementation, track first/second card selection and matching turn = state.turn if turn == 1 do # First card {:reply, {:first_card, slot}, %{state | first_slot: slot, turn: 0}} else # Second card - check match first_card = Enum.at(state.match_cards, state.first_slot - 1) second_card = Enum.at(state.match_cards, slot - 1) if first_card == second_card do # Match! Award point {p0, p1} = state.points new_points = if slot == 1, do: {p0 + 1, p1}, else: {p0, p1 + 1} # Check for game win {p0_new, p1_new} = new_points matches_needed = get_matches_to_win(state.piece_type) if p0_new >= matches_needed or p1_new >= matches_needed do {:reply, {:game_win, slot}, %{state | points: new_points, turn: 1, open: true}} else {:reply, {:match, slot}, %{state | points: new_points, turn: 1}} end else # No match, switch turns next_loser = rem(state.loser + 1, @max_slots) {:reply, {:no_match, slot}, %{state | turn: 1, loser: next_loser}} end end end end @impl true def handle_call({:request_tie, character_id}, _from, state) do slot = get_slot_for_character_internal(state, character_id) if state.tie_requested == -1 do {:reply, :ok, %{state | tie_requested: slot}} else {:reply, {:error, :already_requested}, state} end end @impl true def handle_call({:answer_tie, character_id, accept}, _from, state) do slot = get_slot_for_character_internal(state, character_id) if state.tie_requested != -1 and state.tie_requested != slot do if accept do # Tie accepted {p0, p1} = state.points {:reply, {:tie, slot}, %{state | tie_requested: -1, points: {p0, p1}, open: true}} else {:reply, {:deny, slot}, %{state | tie_requested: -1}} end else {:reply, {:error, :invalid_request}, state} end end @impl true def handle_call({:skip_turn, character_id}, _from, state) do slot = get_slot_for_character_internal(state, character_id) if slot == state.loser + 1 do next_loser = rem(state.loser + 1, @max_slots) {:reply, :ok, %{state | loser: next_loser}} else {:reply, {:error, :not_your_turn}, state} end end @impl true def handle_call({:give_up, character_id}, _from, state) do slot = get_slot_for_character_internal(state, character_id) # Other player wins winner = if slot == 1, do: 2, else: 1 {:reply, {:give_up, winner}, %{state | open: true}} end @impl true def handle_call({:set_exit_after, character_id}, _from, state) do slot = get_slot_for_character_internal(state, character_id) if slot > 0 do {e0, e1} = state.exit_after new_exit = if slot == 1, do: {not e0, e1}, else: {e0, not e1} {:reply, :ok, %{state | exit_after: new_exit}} else {:reply, {:error, :not_visitor}, state} end end @impl true def handle_call({:is_exit_after, character_id}, _from, state) do slot = get_slot_for_character_internal(state, character_id) {e0, e1} = state.exit_after exit = if slot == 1, do: e0, else: e1 {:reply, exit, state} end @impl true def handle_call({:get_visitor_slot, character_id}, _from, state) do slot = get_slot_for_character_internal(state, character_id) {:reply, slot, state} end @impl true def handle_call({:is_owner, character_id}, _from, state) do {:reply, character_id == state.owner_id, state} end @impl true def handle_call(:close_game, _from, state) do # Remove all visitors Enum.each(state.visitors, fn {_id, data} -> send(data.pid, {:game_closed, state.id}) end) {:stop, :normal, :ok, state} end # ============================================================================ # Private Helper Functions # ============================================================================ defp via_tuple(game_id) do {:via, Registry, {Odinsea.MiniGameRegistry, game_id}} end defp generate_id do :erlang.unique_integer([:positive]) end defp get_slot_for_character_internal(state, character_id) do cond do character_id == state.owner_id -> 1 true -> Map.get(state.visitors, character_id, %{}) |> Map.get(:slot, -1) end end defp create_empty_board do for _ <- 1..@omok_board_size do for _ <- 1..@omok_board_size, do: 0 end end defp get_board_piece(board, x, y) do row = Enum.at(board, y) Enum.at(row, x) end defp set_board_piece(board, x, y, piece) do row = Enum.at(board, y) new_row = List.replace_at(row, x, piece) List.replace_at(board, y, new_row) end defp generate_match_cards(piece_type) do matches_needed = get_matches_to_win(piece_type) cards = for i <- 0..(matches_needed - 1) do [i, i] end |> List.flatten() # Shuffle cards Enum.shuffle(cards) end # Omok win checking - check all directions from the last move defp check_omok_win(board, x, y, piece_type) do directions = [ {1, 0}, # Horizontal {0, 1}, # Vertical {1, 1}, # Diagonal \ {1, -1} # Diagonal / ] Enum.any?(directions, fn {dx, dy} -> count = count_in_direction(board, x, y, dx, dy, piece_type) + count_in_direction(board, x, y, -dx, -dy, piece_type) - 1 count >= 5 end) end defp count_in_direction(board, x, y, dx, dy, piece_type, count \\ 0) do if x < 0 or x >= @omok_board_size or y < 0 or y >= @omok_board_size do count else piece = get_board_piece(board, x, y) if piece == piece_type do count_in_direction(board, x + dx, y + dy, dx, dy, piece_type, count + 1) else count end end end end