kimi gone wild
This commit is contained in:
599
lib/odinsea/game/hired_merchant.ex
Normal file
599
lib/odinsea/game/hired_merchant.ex
Normal file
@@ -0,0 +1,599 @@
|
||||
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
|
||||
Reference in New Issue
Block a user