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

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