defmodule Odinsea.Game.HiredMerchant do @moduledoc """ Hired Merchant (permanent NPC shop) system. Ported from src/server/shops/HiredMerchant.java Hired Merchants are permanent shops that: - Stay open even when the owner is offline - Can be placed in the Free Market - Support visitor browsing and buying - Have a blacklist system - Can save items to Fredrick when closed Shop lifecycle: 1. Owner uses hired merchant item 2. Shop is created and items are added 3. Shop stays open for extended period (or until owner closes it) 4. When closed, unsold items and mesos can be retrieved from Fredrick """ use GenServer require Logger alias Odinsea.Game.{ShopItem, Item, Equip} # Shop type constant @shop_type 1 # Maximum visitors @max_visitors 3 # Hired merchant duration (24 hours in milliseconds) @merchant_duration 24 * 60 * 60 * 1000 # Struct for the merchant state defstruct [ :id, :owner_id, :owner_account_id, :owner_name, :item_id, :description, :map_id, :channel, :position, :store_id, :meso, :items, :visitors, :visitor_names, :blacklist, :open, :available, :bought_items, :start_time ] @doc """ Starts a new hired merchant GenServer. """ def start_link(opts) do merchant_id = Keyword.fetch!(opts, :id) GenServer.start_link(__MODULE__, opts, name: via_tuple(merchant_id)) end @doc """ Creates a new hired merchant. """ 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] || "", map_id: opts[:map_id], channel: opts[:channel], position: opts[:position], store_id: 0, meso: 0, items: [], visitors: %{}, visitor_names: [], blacklist: [], open: false, available: false, bought_items: [], start_time: System.system_time(:millisecond) } end @doc """ Returns the shop type (1 = hired merchant). """ def shop_type, do: @shop_type @doc """ Gets the current merchant state. """ def get_state(merchant_pid) when is_pid(merchant_pid) do GenServer.call(merchant_pid, :get_state) end def get_state(merchant_id) do case lookup(merchant_id) do {:ok, pid} -> get_state(pid) error -> error end end @doc """ Looks up a merchant by ID. """ def lookup(merchant_id) do case Registry.lookup(Odinsea.MerchantRegistry, merchant_id) do [{pid, _}] -> {:ok, pid} [] -> {:error, :not_found} end end @doc """ Adds an item to the merchant. """ def add_item(merchant_id, %ShopItem{} = item) do with {:ok, pid} <- lookup(merchant_id) do GenServer.call(pid, {:add_item, item}) end end @doc """ Buys an item from the merchant. Returns {:ok, item, price} on success or {:error, reason} on failure. """ def buy_item(merchant_id, slot, quantity, buyer_id, buyer_name) do with {:ok, pid} <- lookup(merchant_id) do GenServer.call(pid, {:buy_item, slot, quantity, buyer_id, buyer_name}) end end @doc """ Searches for items by item ID in the merchant. """ def search_item(merchant_id, item_id) do with {:ok, pid} <- lookup(merchant_id) do GenServer.call(pid, {:search_item, item_id}) end end @doc """ Adds a visitor to the merchant. Returns the visitor slot (1-3) or {:error, :full/:blacklisted}. """ def add_visitor(merchant_id, character_id, character_name, character_pid) do with {:ok, pid} <- lookup(merchant_id) do GenServer.call(pid, {:add_visitor, character_id, character_name, character_pid}) end end @doc """ Removes a visitor from the merchant. """ def remove_visitor(merchant_id, character_id) do with {:ok, pid} <- lookup(merchant_id) do GenServer.call(pid, {:remove_visitor, character_id}) end end @doc """ Sets the merchant open status. """ def set_open(merchant_id, open) do with {:ok, pid} <- lookup(merchant_id) do GenServer.call(pid, {:set_open, open}) end end @doc """ Sets the merchant available status (visible on map). """ def set_available(merchant_id, available) do with {:ok, pid} <- lookup(merchant_id) do GenServer.call(pid, {:set_available, available}) end end @doc """ Sets the store ID (when registered with channel). """ def set_store_id(merchant_id, store_id) do with {:ok, pid} <- lookup(merchant_id) do GenServer.call(pid, {:set_store_id, store_id}) end end @doc """ Adds a player to the blacklist. """ def add_to_blacklist(merchant_id, character_name) do with {:ok, pid} <- lookup(merchant_id) do GenServer.call(pid, {:add_blacklist, character_name}) end end @doc """ Removes a player from the blacklist. """ def remove_from_blacklist(merchant_id, character_name) do with {:ok, pid} <- lookup(merchant_id) do GenServer.call(pid, {:remove_blacklist, character_name}) end end @doc """ Checks if a player is in the blacklist. """ def is_blacklisted?(merchant_id, character_name) do with {:ok, pid} <- lookup(merchant_id) do GenServer.call(pid, {:is_blacklisted, character_name}) end end @doc """ Gets the visitor list (for owner view). """ def get_visitors(merchant_id) do with {:ok, pid} <- lookup(merchant_id) do GenServer.call(pid, :get_visitors) end end @doc """ Gets the blacklist. """ def get_blacklist(merchant_id) do with {:ok, pid} <- lookup(merchant_id) do GenServer.call(pid, :get_blacklist) end end @doc """ Gets time remaining for the merchant (in seconds). """ def get_time_remaining(merchant_id) do with {:ok, pid} <- lookup(merchant_id) do GenServer.call(pid, :get_time_remaining) end end @doc """ Gets the current meso amount. """ def get_meso(merchant_id) do with {:ok, pid} <- lookup(merchant_id) do GenServer.call(pid, :get_meso) end end @doc """ Sets the meso amount. """ def set_meso(merchant_id, meso) do with {:ok, pid} <- lookup(merchant_id) do GenServer.call(pid, {:set_meso, meso}) end end @doc """ Closes the merchant and saves items. """ def close_merchant(merchant_id, save_items \\ true, remove \\ true) do with {:ok, pid} <- lookup(merchant_id) do GenServer.call(pid, {:close_merchant, save_items, remove}) end end @doc """ Checks if a character is the owner. """ def is_owner?(merchant_id, character_id) do with {:ok, pid} <- lookup(merchant_id) do GenServer.call(pid, {:is_owner, character_id}) end end @doc """ Broadcasts a packet to all visitors. """ def broadcast_to_visitors(merchant_id, packet) do with {:ok, pid} <- lookup(merchant_id) do GenServer.cast(pid, {:broadcast, packet}) end end # ============================================================================ # GenServer Callbacks # ============================================================================ @impl true def init(opts) do state = create(opts) # Schedule expiration check schedule_expiration_check() {: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({: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 -> price = shop_item.price * quantity # Calculate tax (EntrustedStoreTax) tax = calculate_tax(price) net_price = price - tax # Create bought item record 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 meso new_meso = state.meso + net_price # Update state new_bought_items = [bought_record | state.bought_items] new_state = %{ state | items: new_items, meso: new_meso, bought_items: new_bought_items } # Notify owner if online (simplified - would need world lookup) # Logger.info("Merchant item sold: #{shop_item.item.item_id} to #{buyer_name}") {:reply, {:ok, buyer_item, price}, new_state} end end end @impl true def handle_call({:search_item, item_id}, _from, state) do results = Enum.filter(state.items, fn shop_item -> shop_item.item.item_id == item_id and shop_item.bundles > 0 end) {:reply, results, state} end @impl true def handle_call({:add_visitor, character_id, character_name, character_pid}, _from, state) do # Check blacklist if character_name in state.blacklist do {:reply, {:error, :blacklisted}, state} else # Check if already visiting 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, name: character_name }) # Track visitor name for history new_visitor_names = if character_id != state.owner_id do [character_name | 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 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_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({:set_store_id, store_id}, _from, state) do {:reply, :ok, %{state | store_id: store_id}} end @impl true def handle_call({:add_blacklist, character_name}, _from, state) do new_blacklist = if character_name in state.blacklist do state.blacklist else [character_name | state.blacklist] end {:reply, :ok, %{state | blacklist: new_blacklist}} end @impl true def handle_call({:remove_blacklist, character_name}, _from, state) do new_blacklist = List.delete(state.blacklist, character_name) {:reply, :ok, %{state | blacklist: new_blacklist}} end @impl true def handle_call({:is_blacklisted, character_name}, _from, state) do {:reply, character_name in state.blacklist, state} end @impl true def handle_call(:get_visitors, _from, state) do visitor_list = Enum.map(state.visitors, fn {_id, data} -> data.name end) {:reply, visitor_list, state} end @impl true def handle_call(:get_blacklist, _from, state) do {:reply, state.blacklist, state} end @impl true def handle_call(:get_time_remaining, _from, state) do elapsed = System.system_time(:millisecond) - state.start_time remaining = max(0, div(@merchant_duration - elapsed, 1000)) {:reply, remaining, state} 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_call({:close_merchant, save_items, _remove}, _from, state) do # Remove all visitors Enum.each(state.visitors, fn {_id, data} -> send(data.pid, {:merchant_closed, state.id}) end) # Prepare items for saving (to Fredrick) items_to_save = if save_items do 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) else [] end # Return unsold items and meso to owner {:reply, {:ok, items_to_save, state.meso}, %{state | open: false, available: false}} 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_cast({:broadcast, packet}, state) do Enum.each(state.visitors, fn {_id, data} -> send(data.pid, {:merchant_packet, packet}) end) {:noreply, state} end @impl true def handle_info(:check_expiration, state) do elapsed = System.system_time(:millisecond) - state.start_time if elapsed >= @merchant_duration do # Merchant has expired - close it Logger.info("Hired merchant #{state.id} has expired") # Notify owner and save items # In full implementation, this would send to Fredrick {:stop, :normal, state} else schedule_expiration_check() {:noreply, state} end end # ============================================================================ # Private Helper Functions # ============================================================================ defp via_tuple(merchant_id) do {:via, Registry, {Odinsea.MerchantRegistry, merchant_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 case Map.get(state.visitors, character_id) do nil -> -1 data -> data.slot end end defp calculate_tax(amount) do # Simple tax calculation - can be made more complex # Based on GameConstants.EntrustedStoreTax div(amount, 10) end defp schedule_expiration_check do # Check every hour Process.send_after(self(), :check_expiration, 60 * 60 * 1000) end end