531 lines
13 KiB
Elixir
531 lines
13 KiB
Elixir
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
|