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