600 lines
15 KiB
Elixir
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
|