defmodule Odinsea.Game.PlayerShop do @moduledoc """ Player-owned shop (mushroom shop) system. Ported from src/server/shops/MaplePlayerShop.java Player shops allow players to: - Open a shop with a shop permit item - List items for sale with prices - Allow other players to browse and buy - Support up to 3 visitors at once - Can ban unwanted visitors Shop lifecycle: 1. Owner creates shop with description 2. Owner adds items to sell 3. Owner opens shop (becomes visible on map) 4. Visitors can enter and buy items 5. Owner can close shop (returns unsold items) """ use GenServer require Logger alias Odinsea.Game.{ShopItem, Item, Equip} # Shop type constant @shop_type 2 # Maximum visitors (excluding owner) @max_visitors 3 # Struct for the shop state defstruct [ :id, :owner_id, :owner_account_id, :owner_name, :item_id, :description, :password, :map_id, :channel, :position, :meso, :items, :visitors, :visitor_names, :banned_list, :open, :available, :bought_items, :bought_count ] @doc """ Starts a new player shop GenServer. """ def start_link(opts) do shop_id = Keyword.fetch!(opts, :id) GenServer.start_link(__MODULE__, opts, name: via_tuple(shop_id)) end @doc """ Creates a new player shop. """ def create(opts) do %__MODULE__{ id: opts[:id] || generate_id(), owner_id: opts[:owner_id], owner_account_id: opts[:owner_account_id], owner_name: opts[:owner_name], item_id: opts[:item_id], description: opts[:description] || "", password: opts[:password] || "", map_id: opts[:map_id], channel: opts[:channel], position: opts[:position], meso: 0, items: [], visitors: %{}, visitor_names: [], banned_list: [], open: false, available: false, bought_items: [], bought_count: 0 } end @doc """ Returns the shop type (2 = player shop). """ def shop_type, do: @shop_type @doc """ Gets the current shop state. """ def get_state(shop_pid) when is_pid(shop_pid) do GenServer.call(shop_pid, :get_state) end def get_state(shop_id) do case lookup(shop_id) do {:ok, pid} -> get_state(pid) error -> error end end @doc """ Looks up a shop by ID. """ def lookup(shop_id) do case Registry.lookup(Odinsea.ShopRegistry, shop_id) do [{pid, _}] -> {:ok, pid} [] -> {:error, :not_found} end end @doc """ Adds an item to the shop. """ def add_item(shop_id, %ShopItem{} = item) do with {:ok, pid} <- lookup(shop_id) do GenServer.call(pid, {:add_item, item}) end end @doc """ Removes an item from the shop by slot. """ def remove_item(shop_id, slot) do with {:ok, pid} <- lookup(shop_id) do GenServer.call(pid, {:remove_item, slot}) end end @doc """ Buys an item from the shop. Returns {:ok, item, price} on success or {:error, reason} on failure. """ def buy_item(shop_id, slot, quantity, buyer_id, buyer_name) do with {:ok, pid} <- lookup(shop_id) do GenServer.call(pid, {:buy_item, slot, quantity, buyer_id, buyer_name}) end end @doc """ Adds a visitor to the shop. Returns the visitor slot (1-3) or {:error, :full}. """ def add_visitor(shop_id, character_id, character_pid) do with {:ok, pid} <- lookup(shop_id) do GenServer.call(pid, {:add_visitor, character_id, character_pid}) end end @doc """ Removes a visitor from the shop. """ def remove_visitor(shop_id, character_id) do with {:ok, pid} <- lookup(shop_id) do GenServer.call(pid, {:remove_visitor, character_id}) end end @doc """ Bans a player from the shop. """ def ban_player(shop_id, character_name) do with {:ok, pid} <- lookup(shop_id) do GenServer.call(pid, {:ban_player, character_name}) end end @doc """ Checks if a player is banned from the shop. """ def is_banned?(shop_id, character_name) do with {:ok, pid} <- lookup(shop_id) do GenServer.call(pid, {:is_banned, character_name}) end end @doc """ Sets the shop open status. """ def set_open(shop_id, open) do with {:ok, pid} <- lookup(shop_id) do GenServer.call(pid, {:set_open, open}) end end @doc """ Sets the shop available status (visible on map). """ def set_available(shop_id, available) do with {:ok, pid} <- lookup(shop_id) do GenServer.call(pid, {:set_available, available}) end end @doc """ Gets a free visitor slot. Returns slot number (1-3) or nil if full. """ def get_free_slot(shop_id) do with {:ok, pid} <- lookup(shop_id) do GenServer.call(pid, :get_free_slot) end end @doc """ Gets the visitor slot for a character. Returns slot number (0 for owner, 1-3 for visitors, -1 if not found). """ def get_visitor_slot(shop_id, character_id) do with {:ok, pid} <- lookup(shop_id) do GenServer.call(pid, {:get_visitor_slot, character_id}) end end @doc """ Checks if the character is the owner. """ def is_owner?(shop_id, character_id, character_name) do with {:ok, pid} <- lookup(shop_id) do GenServer.call(pid, {:is_owner, character_id, character_name}) end end @doc """ Closes the shop and returns unsold items. """ def close_shop(shop_id, save_items \\ false) do with {:ok, pid} <- lookup(shop_id) do GenServer.call(pid, {:close_shop, save_items}) end end @doc """ Gets the current meso amount in the shop. """ def get_meso(shop_id) do with {:ok, pid} <- lookup(shop_id) do GenServer.call(pid, :get_meso) end end @doc """ Sets the meso amount in the shop. """ def set_meso(shop_id, meso) do with {:ok, pid} <- lookup(shop_id) do GenServer.call(pid, {:set_meso, meso}) end end @doc """ Broadcasts a packet to all visitors. """ def broadcast_to_visitors(shop_id, packet, include_owner \\ true) do with {:ok, pid} <- lookup(shop_id) do GenServer.cast(pid, {:broadcast, packet, include_owner}) 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_item, item}, _from, state) do new_items = state.items ++ [item] {:reply, :ok, %{state | items: new_items}} end @impl true def handle_call({:remove_item, slot}, _from, state) do if slot >= 0 and slot < length(state.items) do {removed, new_items} = List.pop_at(state.items, slot) {:reply, {:ok, removed}, %{state | items: new_items}} else {:reply, {:error, :invalid_slot}, state} end end @impl true def handle_call({:buy_item, slot, quantity, buyer_id, buyer_name}, _from, state) do cond do slot < 0 or slot >= length(state.items) -> {:reply, {:error, :invalid_slot}, state} true -> shop_item = Enum.at(state.items, slot) cond do shop_item.bundles < quantity -> {:reply, {:error, :not_enough_stock}, state} true -> # Create bought item record price = shop_item.price * quantity bought_record = %{ item_id: shop_item.item.item_id, quantity: quantity, total_price: price, buyer: buyer_name } # Reduce bundles updated_item = ShopItem.reduce_bundles(shop_item, quantity) # Update items list new_items = if ShopItem.sold_out?(updated_item) do List.delete_at(state.items, slot) else List.replace_at(state.items, slot, updated_item) end # Create item for buyer buyer_item = ShopItem.create_buyer_item(shop_item, quantity) # Update state new_bought_items = [bought_record | state.bought_items] new_bought_count = state.bought_count + 1 # Check if all items sold should_close = new_bought_count >= length(state.items) and new_items == [] new_state = %{ state | items: new_items, bought_items: new_bought_items, bought_count: new_bought_count } if should_close do {:reply, {:ok, buyer_item, price, :close}, new_state} else {:reply, {:ok, buyer_item, price, :continue}, new_state} end end end end @impl true def handle_call({:add_visitor, character_id, character_pid}, _from, state) do # Check if already a visitor if Map.has_key?(state.visitors, character_id) do slot = get_slot_for_character(state, character_id) {:reply, {:ok, slot}, state} else # Find free slot case find_free_slot(state) do nil -> {:reply, {:error, :full}, state} slot -> new_visitors = Map.put(state.visitors, character_id, %{pid: character_pid, slot: slot}) # Track visitor name for history new_visitor_names = if character_id != state.owner_id do [character_id | state.visitor_names] else state.visitor_names end new_state = %{state | visitors: new_visitors, visitor_names: new_visitor_names} {:reply, {:ok, slot}, new_state} end 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({:ban_player, character_name}, _from, state) do # Add to banned list new_banned = if character_name in state.banned_list do state.banned_list else [character_name | state.banned_list] end # Find and remove if currently visiting visitor_to_remove = Enum.find(state.visitors, fn {_id, data} -> # This would need the character name, which we don't have in the state # For now, just ban from future visits false end) new_visitors = case visitor_to_remove do {id, _} -> Map.delete(state.visitors, id) nil -> state.visitors end {:reply, :ok, %{state | banned_list: new_banned, visitors: new_visitors}} end @impl true def handle_call({:is_banned, character_name}, _from, state) do {:reply, character_name in state.banned_list, state} end @impl true def handle_call({:set_open, open}, _from, state) do {:reply, :ok, %{state | open: open}} end @impl true def handle_call({:set_available, available}, _from, state) do {:reply, :ok, %{state | available: available}} end @impl true def handle_call(:get_free_slot, _from, state) do {:reply, find_free_slot(state), state} end @impl true def handle_call({:get_visitor_slot, character_id}, _from, state) do slot = get_slot_for_character(state, character_id) {:reply, slot, state} end @impl true def handle_call({:is_owner, character_id, character_name}, _from, state) do is_owner = character_id == state.owner_id and character_name == state.owner_name {:reply, is_owner, state} end @impl true def handle_call({:close_shop, _save_items}, _from, state) do # Remove all visitors Enum.each(state.visitors, fn {_id, data} -> send(data.pid, {:shop_closed, state.id}) end) # Return unsold items to owner unsold_items = Enum.filter(state.items, fn item -> item.bundles > 0 end) |> Enum.map(fn shop_item -> item = shop_item.item total_qty = shop_item.bundles * item.quantity %{item | quantity: total_qty} end) {:reply, {:ok, unsold_items, state.meso}, %{state | open: false, available: false}} end @impl true def handle_call(:get_meso, _from, state) do {:reply, state.meso, state} end @impl true def handle_call({:set_meso, meso}, _from, state) do {:reply, :ok, %{state | meso: meso}} end @impl true def handle_cast({:broadcast, packet, include_owner}, state) do # Broadcast to all visitors Enum.each(state.visitors, fn {_id, data} -> send(data.pid, {:shop_packet, packet}) end) # Optionally broadcast to owner if include_owner do # Owner would receive via their own channel :ok end {:noreply, state} end # ============================================================================ # Private Helper Functions # ============================================================================ defp via_tuple(shop_id) do {:via, Registry, {Odinsea.ShopRegistry, shop_id}} end defp generate_id do :erlang.unique_integer([:positive]) end defp find_free_slot(state) do used_slots = Map.values(state.visitors) |> Enum.map(& &1.slot) Enum.find(1..@max_visitors, fn slot -> slot not in used_slots end) end defp get_slot_for_character(state, character_id) do cond do character_id == state.owner_id -> 0 true -> case Map.get(state.visitors, character_id) do nil -> -1 data -> data.slot end end end end