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