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

783 lines
20 KiB
Elixir

defmodule Odinsea.Shop.MTS do
@moduledoc """
Maple Trading System (MTS) implementation.
The MTS allows players to:
- List items for sale (buy now)
- Purchase items from other players
- Search for items
- Manage their MTS cart
Ported from handling/cashshop/handler/MTSOperation.java
and server/MTSStorage.java / server/MTSCart.java
"""
use GenServer
require Logger
alias Odinsea.Game.Inventory
# MTS opcodes
@mts_sell 2
@mts_page 5
@mts_search 6
@mts_cancel 7
@mts_transfer 8
@mts_add_cart 9
@mts_del_cart 10
@mts_buy_now 16
@mts_buy_cart 17
# MTS Constants
@min_price 100
@mts_meso 5000
@listing_duration_days 7
# ETS tables
@mts_items :odinsea_mts_items
@mts_carts :odinsea_mts_carts
defstruct [
:id,
:item,
:price,
:seller_id,
:seller_name,
:expiration,
:buyer_id
]
## Public API
def start_link(opts \\ []) do
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
end
@doc """
Gets or creates a cart for a character.
"""
@spec get_cart(integer()) :: map()
def get_cart(character_id) do
case :ets.lookup(@mts_carts, character_id) do
[{^character_id, cart}] ->
cart
[] ->
cart = %{
character_id: character_id,
cart: [],
inventory: [],
not_yet_sold: [],
owed_nx: 0,
tab: 0,
page: 0,
type: 0,
current_view: []
}
:ets.insert(@mts_carts, {character_id, cart})
cart
end
end
@doc """
Updates cart view settings.
"""
@spec change_cart_info(integer(), integer(), integer(), integer()) :: :ok
def change_cart_info(character_id, tab, page, type) do
cart = get_cart(character_id)
new_cart = %{
cart
| tab: tab,
page: page,
type: type
}
:ets.insert(@mts_carts, {character_id, new_cart})
:ok
end
@doc """
Updates current view (search results).
"""
@spec change_current_view(integer(), [map()]) :: :ok
def change_current_view(character_id, items) do
cart = get_cart(character_id)
new_cart = %{cart | current_view: items}
:ets.insert(@mts_carts, {character_id, new_cart})
:ok
end
@doc """
Lists an item for sale on the MTS.
"""
@spec list_item(integer(), map(), integer(), String.t()) ::
{:ok, integer()} | {:error, atom()}
def list_item(seller_id, item, price, seller_name) do
if price < @min_price do
{:error, :price_too_low}
else
expiration = Odinsea.now() + @listing_duration_days * 24 * 60 * 60 * 1000
listing = %__MODULE__{
id: generate_listing_id(),
item: item,
price: price,
seller_id: seller_id,
seller_name: seller_name,
expiration: expiration,
buyer_id: nil
}
:ets.insert(@mts_items, {listing.id, listing})
# Add to seller's "not yet sold" list
cart = get_cart(seller_id)
new_not_yet_sold = [listing.id | cart.not_yet_sold]
new_cart = %{cart | not_yet_sold: new_not_yet_sold}
:ets.insert(@mts_carts, {seller_id, new_cart})
{:ok, listing.id}
end
end
@doc """
Gets a single MTS item by ID.
"""
@spec get_item(integer()) :: map() | nil
def get_item(id) do
case :ets.lookup(@mts_items, id) do
[{^id, item}] -> item
[] -> nil
end
end
@doc """
Removes an item from the MTS.
Returns the item to the seller's transfer inventory if canceling.
"""
@spec remove_item(integer(), integer(), boolean()) :: boolean()
def remove_item(id, character_id, cancel) do
case get_item(id) do
nil ->
false
item ->
if item.seller_id != character_id do
false
else
:ets.delete(@mts_items, id)
if cancel do
# Return item to seller's transfer inventory
cart = get_cart(character_id)
new_inventory = [item.item | cart.inventory]
new_not_yet_sold = List.delete(cart.not_yet_sold, id)
new_cart = %{
cart
| inventory: new_inventory,
not_yet_sold: new_not_yet_sold
}
:ets.insert(@mts_carts, {character_id, new_cart})
end
true
end
end
end
@doc """
Buys an item from the MTS.
"""
@spec buy_item(integer(), integer(), integer()) ::
{:ok, map()} | {:error, atom()}
def buy_item(id, buyer_id, offered_price) do
case get_item(id) do
nil ->
{:error, :not_found}
item ->
if item.seller_id == buyer_id do
{:error, :own_item}
else
if offered_price < item.price do
{:error, :insufficient_funds}
else
# Mark as sold and transfer to buyer
:ets.delete(@mts_items, id)
# Add to buyer's transfer inventory
buyer_cart = get_cart(buyer_id)
new_buyer_inventory = [item.item | buyer_cart.inventory]
new_buyer_cart = %{buyer_cart | inventory: new_buyer_inventory}
:ets.insert(@mts_carts, {buyer_id, new_buyer_cart})
# Credit seller with NX
seller_cart = get_cart(item.seller_id)
new_owed = seller_cart.owed_nx + item.price
new_not_yet_sold = List.delete(seller_cart.not_yet_sold, id)
new_seller_cart = %{
seller_cart
| owed_nx: new_owed,
not_yet_sold: new_not_yet_sold
}
:ets.insert(@mts_carts, {item.seller_id, new_seller_cart})
{:ok, item}
end
end
end
end
@doc """
Searches for items in the MTS.
"""
@spec search(boolean(), String.t(), integer(), integer()) :: [map()]
def search(_cash_search, search_string, type, tab) do
# Get all items
all_items =
:ets.select(@mts_items, [{{:_, :"$1"}, [], [:"$1"]}])
|> Enum.filter(&is_nil(&1.buyer_id))
# Apply filters
items =
cond do
# Tab 0 = all items
tab == 0 ->
all_items
# Tab 1 = search by name
tab == 1 && search_string != "" ->
Enum.filter(all_items, fn item ->
item_name = get_item_name(item.item.item_id)
String.contains?(String.downcase(item_name), String.downcase(search_string))
end)
# Type filtering
type > 0 ->
Enum.filter(all_items, fn item ->
get_item_type(item.item.item_id) == type
end)
true ->
all_items
end
# Sort by newest first
Enum.sort_by(items, & &1.id, :desc)
end
@doc """
Adds an item to the cart.
"""
@spec add_to_cart(integer(), integer()) :: boolean()
def add_to_cart(character_id, item_id) do
cart = get_cart(character_id)
if item_id in cart.cart do
false
else
new_cart = %{cart | cart: [item_id | cart.cart]}
:ets.insert(@mts_carts, {character_id, new_cart})
true
end
end
@doc """
Removes an item from the cart.
"""
@spec remove_from_cart(integer(), integer()) :: boolean()
def remove_from_cart(character_id, item_id) do
cart = get_cart(character_id)
if item_id in cart.cart do
new_cart = %{cart | cart: List.delete(cart.cart, item_id)}
:ets.insert(@mts_carts, {character_id, new_cart})
true
else
false
end
end
@doc """
Transfers an item from MTS inventory to game inventory.
"""
@spec transfer_item(integer(), integer()) :: {:ok, map()} | {:error, atom()}
def transfer_item(character_id, index) do
cart = get_cart(character_id)
if index < 0 || index >= length(cart.inventory) do
{:error, :invalid_index}
else
item = Enum.at(cart.inventory, index)
new_inventory = List.delete_at(cart.inventory, index)
new_cart = %{cart | inventory: new_inventory}
:ets.insert(@mts_carts, {character_id, new_cart})
{:ok, item}
end
end
@doc """
Claims owed NX for a character.
"""
@spec claim_nx(integer()) :: integer()
def claim_nx(character_id) do
cart = get_cart(character_id)
owed = cart.owed_nx
if owed > 0 do
new_cart = %{cart | owed_nx: 0}
:ets.insert(@mts_carts, {character_id, new_cart})
end
owed
end
@doc """
Checks and removes expired listings.
"""
@spec check_expirations() :: :ok
def check_expirations do
now = Odinsea.now()
expired =
:ets.select(@mts_items, [{{:_, :"$1"}, [], [:"$1"]}])
|> Enum.filter(fn item -> item.expiration < now end)
Enum.each(expired, fn item ->
:ets.delete(@mts_items, item.id)
# Return item to seller
cart = get_cart(item.seller_id)
new_inventory = [item.item | cart.inventory]
new_not_yet_sold = List.delete(cart.not_yet_sold, item.id)
new_cart = %{
cart
| inventory: new_inventory,
not_yet_sold: new_not_yet_sold
}
:ets.insert(@mts_carts, {item.seller_id, new_cart})
end)
:ok
end
@doc """
Gets current MTS listings for display.
"""
@spec get_current_mts(map()) :: [map()]
def get_current_mts(cart) do
page_size = 16
start_idx = cart.page * page_size
items =
if cart.tab == 0 do
:ets.select(@mts_items, [{{:_, :"$1"}, [], [:"$1"]}])
else
cart.current_view
end
items
|> Enum.filter(&is_nil(&1.buyer_id))
|> Enum.slice(start_idx, page_size)
end
@doc """
Gets "not yet sold" listings for a character.
"""
@spec get_not_yet_sold(integer()) :: [map()]
def get_not_yet_sold(character_id) do
cart = get_cart(character_id)
Enum.map(cart.not_yet_sold, &get_item/1)
|> Enum.filter(&(&1 != nil))
end
@doc """
Gets transfer inventory for a character.
"""
@spec get_transfer(integer()) :: [map()]
def get_transfer(character_id) do
cart = get_cart(character_id)
cart.inventory
end
@doc """
Checks if an item is in the cart.
"""
@spec in_cart?(integer(), integer()) :: boolean()
def in_cart?(character_id, item_id) do
cart = get_cart(character_id)
item_id in cart.cart
end
@doc """
Handles MTS operation packets.
"""
@spec handle(In.t(), map()) :: map()
def handle(packet, client_state) do
if In.remaining(packet) == 0 do
# Empty packet - just refresh
send_mts_packets(client_state)
else
{op, packet} = In.decode_byte(packet)
handle_op(op, packet, client_state)
end
end
## GenServer Callbacks
@impl true
def init(_opts) do
:ets.new(@mts_items, [:set, :public, :named_table, read_concurrency: true])
:ets.new(@mts_carts, [:set, :public, :named_table])
# Schedule expiration check
schedule_expiration_check()
{:ok, %{}}
end
@impl true
def handle_info(:check_expirations, state) do
check_expirations()
schedule_expiration_check()
{:noreply, state}
end
## Private Functions
defp handle_op(@mts_sell, packet, client_state) do
{inv_type, packet} = In.decode_byte(packet)
{item_id, packet} = In.decode_int(packet)
{has_unique_id, packet} = In.decode_byte(packet)
if has_unique_id != 0 || (inv_type != 1 && inv_type != 2) do
send_mts_fail_sell(client_state)
client_state
else
# Parse item data from packet
{item_data, packet} = parse_item_data(packet, inv_type)
{price, _packet} = In.decode_int(packet)
character = client_state.character
# Validate item can be sold
with :ok <- validate_mts_item(character, item_id, item_data, inv_type),
:ok <- check_meso_fee(character),
true <- length(get_cart(character.id).not_yet_sold) < 10 do
# Create item copy
item = create_mts_item(character, item_id, item_data)
# List on MTS
{:ok, _listing_id} = list_item(character.id, item, price, character.name)
# Deduct meso and remove from inventory
new_character = deduct_meso(character, @mts_meso)
new_character = remove_from_inventory(new_character, inv_type, item_data.slot)
client_state
|> Map.put(:character, new_character)
|> send_mts_confirm_sell()
else
_ ->
send_mts_fail_sell(client_state)
client_state
end
end
end
defp handle_op(@mts_page, packet, client_state) do
{tab, packet} = In.decode_int(packet)
{page, packet} = In.decode_int(packet)
{type, _packet} = In.decode_int(packet)
change_cart_info(client_state.character.id, tab, page, type)
send_mts_packets(client_state)
end
defp handle_op(@mts_search, packet, client_state) do
{tab, packet} = In.decode_int(packet)
{page, packet} = In.decode_int(packet)
{_zero, packet} = In.decode_int(packet)
{cash_search, packet} = In.decode_int(packet)
{search_string, _packet} = In.decode_string(packet)
cart = get_cart(client_state.character.id)
change_cart_info(client_state.character.id, tab, page, cart.type)
# Perform search
results = search(cash_search > 0, search_string, cart.type, tab)
change_current_view(client_state.character.id, results)
send_mts_packets(client_state)
end
defp handle_op(@mts_cancel, packet, client_state) do
{id, _packet} = In.decode_int(packet)
if remove_item(id, client_state.character.id, true) do
send_mts_confirm_cancel(client_state)
send_mts_packets(client_state)
else
send_mts_fail_cancel(client_state)
client_state
end
end
defp handle_op(@mts_transfer, packet, client_state) do
# Fake ID encoding
{fake_id, _packet} = In.decode_int(packet)
index = Integer.pow(2, 31) - 1 - fake_id
case transfer_item(client_state.character.id, index) do
{:ok, item} ->
# Add to inventory
case add_to_inventory(client_state.character, item) do
{:ok, new_character, position} ->
client_state
|> Map.put(:character, new_character)
|> send_mts_confirm_transfer(item, position)
|> send_mts_packets()
{:error, _} ->
send_mts_fail_buy(client_state)
client_state
end
{:error, _} ->
send_mts_fail_buy(client_state)
client_state
end
end
defp handle_op(@mts_add_cart, packet, client_state) do
{id, _packet} = In.decode_int(packet)
if in_cart?(client_state.character.id, id) do
send_cart_message(client_state, true, false)
else
if add_to_cart(client_state.character.id, id) do
send_cart_message(client_state, false, false)
else
send_cart_message(client_state, true, false)
end
end
client_state
end
defp handle_op(@mts_del_cart, packet, client_state) do
{id, _packet} = In.decode_int(packet)
if remove_from_cart(client_state.character.id, id) do
send_cart_message(client_state, false, true)
else
send_cart_message(client_state, true, true)
end
client_state
end
defp handle_op(op, packet, client_state) when op in [@mts_buy_now, @mts_buy_cart] do
{id, _packet} = In.decode_int(packet)
case get_item(id) do
nil ->
send_mts_fail_buy(client_state)
client_state
item ->
if item.seller_id == client_state.character.id do
send_mts_fail_buy(client_state)
client_state
else
# Check buyer has enough NX
character = client_state.character
if (character.nx_cash || 0) >= item.price do
case buy_item(id, character.id, item.price) do
{:ok, _} ->
# Deduct NX
new_character = %{character | nx_cash: character.nx_cash - item.price}
client_state
|> Map.put(:character, new_character)
|> send_mts_confirm_buy()
|> send_mts_packets()
{:error, _} ->
send_mts_fail_buy(client_state)
client_state
end
else
send_mts_fail_buy(client_state)
client_state
end
end
end
end
defp handle_op(_op, _packet, client_state) do
# Unknown op - just refresh
send_mts_packets(client_state)
end
defp parse_item_data(packet, 1) do
# Equipment item data
packet = In.skip(packet, 32) # Skip various stats
{_owner, packet} = In.decode_string(packet)
packet = In.skip(packet, 50)
{slot, packet} = In.decode_int(packet)
packet = In.skip(packet, 4)
{%{slot: slot}, packet}
end
defp parse_item_data(packet, 2) do
# Regular item data
{stars, packet} = In.decode_short(packet)
{_owner, packet} = In.decode_string(packet)
packet = In.skip(packet, 2) # Flag
{slot, packet} = In.decode_int(packet)
{quantity, _packet} = In.decode_int(packet)
{%{slot: slot, quantity: quantity, stars: stars}, packet}
end
defp validate_mts_item(character, item_id, item_data, inv_type) do
# Check item exists in inventory
inventory = Map.get(character.inventories, inv_type, [])
case Enum.find(inventory, &(&1.position == item_data.slot)) do
nil ->
:error
item ->
if item.item_id == item_id && item.quantity >= (item_data.quantity || 1) do
:ok
else
:error
end
end
end
defp check_meso_fee(character) do
if character.meso >= @mts_meso do
:ok
else
:error
end
end
defp create_mts_item(character, item_id, item_data) do
%{
item_id: item_id,
quantity: item_data.quantity || 1,
owner: character.name,
flag: 0
}
end
defp deduct_meso(character, amount) do
%{character | meso: character.meso - amount}
end
defp remove_from_inventory(character, inv_type, slot) do
inventory = Map.get(character.inventories, inv_type, [])
new_inventory = Enum.reject(inventory, &(&1.position == slot))
inventories = Map.put(character.inventories, inv_type, new_inventory)
%{character | inventories: inventories}
end
defp add_to_inventory(character, item) do
inv_type = Odinsea.Game.InventoryType.from_item_id(item.item_id)
if Odinsea.Shop.Operation.check_inventory_space(character, item.item_id, item.quantity) == :ok do
inventory = Map.get(character.inventories, inv_type, [])
position = Inventory.next_free_slot(inventory)
new_item = %{item | position: position}
new_inventory = [new_item | inventory]
inventories = Map.put(character.inventories, inv_type, new_inventory)
{:ok, %{character | inventories: inventories}, position}
else
{:error, :no_space}
end
end
defp get_item_name(item_id) do
Odinsea.Game.ItemInfo.get_name(item_id) || "Unknown"
end
defp get_item_type(item_id) do
cond do
item_id >= 1_000_000 && item_id < 2_000_000 -> 1
item_id >= 2_000_000 && item_id < 3_000_000 -> 2
item_id >= 4_000_000 && item_id < 5_000_000 -> 4
true -> 0
end
end
defp generate_listing_id do
:erlang.unique_integer([:positive])
end
defp schedule_expiration_check do
# Check every hour
Process.send_after(self(), :check_expirations, 60 * 60 * 1000)
end
# Packet senders
defp send_mts_packets(client_state) do
cart = get_cart(client_state.character.id)
Odinsea.Shop.Packets.send_current_mts(client_state.socket, cart)
Odinsea.Shop.Packets.send_not_yet_sold(client_state.socket, cart)
Odinsea.Shop.Packets.send_transfer(client_state.socket, cart)
Odinsea.Shop.Packets.show_mts_cash(client_state.socket, client_state.character)
Odinsea.Shop.Packets.enable_cs_use(client_state.socket)
client_state
end
defp send_mts_fail_sell(client_state) do
Odinsea.Shop.Packets.get_mts_fail_sell(client_state.socket)
end
defp send_mts_confirm_sell(client_state) do
Odinsea.Shop.Packets.get_mts_confirm_sell(client_state.socket)
end
defp send_mts_fail_cancel(client_state) do
Odinsea.Shop.Packets.get_mts_fail_cancel(client_state.socket)
end
defp send_mts_confirm_cancel(client_state) do
Odinsea.Shop.Packets.get_mts_confirm_cancel(client_state.socket)
end
defp send_mts_fail_buy(client_state) do
Odinsea.Shop.Packets.get_mts_fail_buy(client_state.socket)
end
defp send_mts_confirm_buy(client_state) do
Odinsea.Shop.Packets.get_mts_confirm_buy(client_state.socket)
end
defp send_mts_confirm_transfer(client_state, _item, position) do
# This needs the inventory type encoded
Odinsea.Shop.Packets.get_mts_confirm_transfer(client_state.socket, 1, position)
end
defp send_cart_message(client_state, failed, deleted) do
Odinsea.Shop.Packets.add_to_cart_message(client_state.socket, failed, deleted)
end
end