Files
odinsea-elixir/lib/odinsea/game/hired_merchant.ex
2026-02-14 23:12:33 -07:00

600 lines
15 KiB
Elixir

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