kimi gone wild

This commit is contained in:
ra
2026-02-14 23:12:33 -07:00
parent bbd205ecbe
commit 0222be36c5
98 changed files with 39726 additions and 309 deletions

View File

@@ -0,0 +1,186 @@
defmodule Odinsea.Shop.CashItem do
@moduledoc """
Cash Shop Item struct and utilities.
Represents an item available for purchase in the Cash Shop.
Ported from server/CashItemInfo.java and server/cash/CashCommodity.java
"""
@type t :: %__MODULE__{
sn: integer(),
item_id: integer(),
price: integer(),
count: integer(),
period: integer(),
gender: integer(),
on_sale: boolean(),
class: integer(),
priority: integer(),
is_package: boolean(),
meso_price: integer(),
bonus: integer(),
for_premium_user: integer(),
limit: integer(),
extra_flags: integer()
}
defstruct [
:sn,
:item_id,
:price,
:count,
:period,
:gender,
:on_sale,
:class,
:priority,
:is_package,
:meso_price,
:bonus,
:for_premium_user,
:limit,
:extra_flags
]
@doc """
Creates a new CashItem struct from parsed data.
"""
@spec new(map()) :: t()
def new(attrs) do
%__MODULE__{
sn: Map.get(attrs, :sn, 0),
item_id: Map.get(attrs, :item_id, 0),
price: Map.get(attrs, :price, 0),
count: Map.get(attrs, :count, 1),
period: Map.get(attrs, :period, 0),
gender: Map.get(attrs, :gender, 2),
on_sale: Map.get(attrs, :on_sale, false),
class: Map.get(attrs, :class, 0),
priority: Map.get(attrs, :priority, 0),
is_package: Map.get(attrs, :is_package, false),
meso_price: Map.get(attrs, :meso_price, 0),
bonus: Map.get(attrs, :bonus, 0),
for_premium_user: Map.get(attrs, :for_premium_user, 0),
limit: Map.get(attrs, :limit, 0),
extra_flags: Map.get(attrs, :extra_flags, 0)
}
end
@doc """
Checks if the item gender matches the player's gender.
Gender: 0 = male, 1 = female, 2 = both
"""
@spec gender_matches?(t(), integer()) :: boolean()
def gender_matches?(%__MODULE__{gender: 2}, _player_gender), do: true
def gender_matches?(%__MODULE__{gender: gender}, player_gender), do: gender == player_gender
@doc """
Calculates the flags value for packet encoding.
This follows the Java CashCommodity flag calculation.
"""
@spec calculate_flags(t()) :: integer()
def calculate_flags(item) do
flags = item.extra_flags || 0
flags = if item.item_id > 0, do: Bitwise.bor(flags, 0x1), else: flags
flags = if item.count > 0, do: Bitwise.bor(flags, 0x2), else: flags
flags = if item.price > 0, do: Bitwise.bor(flags, 0x4), else: flags
flags = if item.bonus > 0, do: Bitwise.bor(flags, 0x8), else: flags
flags = if item.priority >= 0, do: Bitwise.bor(flags, 0x10), else: flags
flags = if item.period > 0, do: Bitwise.bor(flags, 0x20), else: flags
# 0x40 = nMaplePoint (not used)
flags = if item.meso_price > 0, do: Bitwise.bor(flags, 0x80), else: flags
flags = if item.for_premium_user > 0, do: Bitwise.bor(flags, 0x100), else: flags
flags = if item.gender >= 0, do: Bitwise.bor(flags, 0x200), else: flags
flags = if item.on_sale, do: Bitwise.bor(flags, 0x400), else: flags
flags = if item.class >= -1 && item.class <= 3, do: Bitwise.bor(flags, 0x800), else: flags
flags = if item.limit > 0, do: Bitwise.bor(flags, 0x1000), else: flags
# 0x2000, 0x4000, 0x8000 = nPbCash, nPbPoint, nPbGift (not used)
flags = if item.is_package, do: Bitwise.bor(flags, 0x40000), else: flags
# 0x80000, 0x100000 = term start/end (not used)
flags
end
@doc """
Checks if this is a cash item (premium currency item).
"""
@spec cash_item?(integer()) :: boolean()
def cash_item?(item_id) do
# Cash items typically have IDs in certain ranges
# This is a simplified check - full implementation would check WZ data
item_id >= 500_000 && item_id < 600_000
end
@doc """
Checks if this item is a pet.
"""
@spec pet?(t()) :: boolean()
def pet?(%__MODULE__{item_id: item_id}) do
item_id >= 5_000_000 && item_id < 5_100_000
end
@doc """
Checks if this is a permanent pet.
"""
@spec permanent_pet?(t()) :: boolean()
def permanent_pet?(%__MODULE__{item_id: item_id}) do
item_id >= 5_000_100 && item_id < 5_000_200
end
@doc """
Gets the effective period for this item.
Returns period in days, or special values for permanent items.
"""
@spec effective_period(t()) :: integer()
def effective_period(%__MODULE__{period: period} = item) do
cond do
# Permanent pets have special handling
permanent_pet?(item) -> 20_000
# Default period for non-equip cash items that aren't permanent
period <= 0 && !equip_item?(item) -> 90
true -> period
end
end
@doc """
Checks if this item is equipment.
"""
@spec equip_item?(t()) :: boolean()
def equip_item?(%__MODULE__{item_id: item_id}) do
item_id >= 1_000_000
end
@doc """
Calculates the expiration timestamp for this item.
"""
@spec expiration_time(t()) :: integer()
def expiration_time(item) do
period = effective_period(item)
if period > 0 do
Odinsea.now() + period * 24 * 60 * 60 * 1000
else
-1
end
end
@doc """
Applies modified item info (from cashshop_modified_items table).
"""
@spec apply_mods(t(), map()) :: t()
def apply_mods(item, mods) do
%__MODULE__{
item
| item_id: Map.get(mods, :item_id, item.item_id),
price: Map.get(mods, :price, item.price),
count: Map.get(mods, :count, item.count),
period: Map.get(mods, :period, item.period),
gender: Map.get(mods, :gender, item.gender),
on_sale: Map.get(mods, :on_sale, item.on_sale),
class: Map.get(mods, :class, item.class),
priority: Map.get(mods, :priority, item.priority),
is_package: Map.get(mods, :is_package, item.is_package)
}
end
end

View File

@@ -0,0 +1,367 @@
defmodule Odinsea.Shop.CashItemFactory do
@moduledoc """
Cash Item Factory - loads and caches cash shop item data.
This module loads cash shop item data from JSON files and caches it in ETS
for fast lookups. Ported from server/CashItemFactory.java.
Data sources:
- cash_items.json: Base item definitions (from WZ Commodity.img)
- cash_packages.json: Package item definitions
- cash_mods.json: Modified item info (from database)
"""
use GenServer
require Logger
alias Odinsea.Shop.CashItem
# ETS table names
@item_cache :odinsea_cash_items
@package_cache :odinsea_cash_packages
@category_cache :odinsea_cash_categories
# Data file paths (relative to priv directory)
@items_file "data/cash_items.json"
@packages_file "data/cash_packages.json"
@categories_file "data/cash_categories.json"
@mods_file "data/cash_mods.json"
# Best items (featured items for the main page)
@best_items [
100_030_55,
100_030_90,
101_034_64,
100_029_60,
101_033_63
]
## Public API
@doc "Starts the CashItemFactory GenServer"
def start_link(opts \\ []) do
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
end
@doc "Gets a cash item by SN (serial number)"
@spec get_item(integer()) :: CashItem.t() | nil
def get_item(sn) do
case :ets.lookup(@item_cache, sn) do
[{^sn, item}] -> item
[] -> nil
end
end
@doc "Gets a simple item by SN (without modification check)"
@spec get_simple_item(integer()) :: CashItem.t() | nil
def get_simple_item(sn) do
get_item(sn)
end
@doc "Gets all items in a package by item ID"
@spec get_package_items(integer()) :: [integer()] | nil
def get_package_items(item_id) do
case :ets.lookup(@package_cache, item_id) do
[{^item_id, items}] -> items
[] -> nil
end
end
@doc "Gets all cash items"
@spec get_all_items() :: [CashItem.t()]
def get_all_items do
:ets.select(@item_cache, [{{:_, :"$1"}, [], [:"$1"]}])
end
@doc "Gets items that are currently on sale"
@spec get_sale_items() :: [CashItem.t()]
def get_sale_items do
get_all_items()
|> Enum.filter(& &1.on_sale)
end
@doc "Gets items by category"
@spec get_items_by_category(integer()) :: [CashItem.t()]
def get_items_by_category(category_id) do
# Filter items by category - simplified implementation
# Full implementation would check category mappings
get_all_items()
|> Enum.filter(fn item ->
# Check if item belongs to category based on item_id
# This is a simplified check
case category_id do
1 -> item.item_id >= 5_000_000 && item.item_id < 5_010_000
2 -> item.item_id >= 5_100_000 && item.item_id < 5_110_000
3 -> item.item_id >= 1_700_000 && item.item_id < 1_800_000
_ -> true
end
end)
end
@doc "Gets the best/featured items"
@spec get_best_items() :: [integer()]
def get_best_items do
@best_items
end
@doc "Gets all categories"
@spec get_categories() :: [map()]
def get_categories do
:ets.select(@category_cache, [{{:_, :"$1"}, [], [:"$1"]}])
end
@doc "Gets a category by ID"
@spec get_category(integer()) :: map() | nil
def get_category(category_id) do
case :ets.lookup(@category_cache, category_id) do
[{^category_id, cat}] -> cat
[] -> nil
end
end
@doc "Checks if an item is blocked from cash shop purchase"
@spec blocked?(integer()) :: boolean()
def blocked?(item_id) do
# List of blocked item IDs (hacks, exploits, etc.)
blocked_ids = [
# Add specific blocked item IDs here
]
item_id in blocked_ids
end
@doc "Checks if an item should be ignored (weapon skins, etc.)"
@spec ignore_weapon?(integer()) :: boolean()
def ignore_weapon?(item_id) do
# Ignore certain weapon skin items
false
end
@doc "Reloads cash item data from files"
@spec reload() :: :ok
def reload do
GenServer.call(__MODULE__, :reload, :infinity)
end
@doc "Generates random featured items"
@spec generate_featured() :: [integer()]
def generate_featured do
# Get all on-sale items
sale_items =
get_all_items()
|> Enum.filter(& &1.on_sale)
|> Enum.map(& &1.item_id)
# Return random selection or defaults
if length(sale_items) > 10 do
sale_items
|> Enum.shuffle()
|> Enum.take(10)
else
@best_items
end
end
## GenServer Callbacks
@impl true
def init(_opts) do
# Create ETS tables
:ets.new(@item_cache, [:set, :public, :named_table, read_concurrency: true])
:ets.new(@package_cache, [:set, :public, :named_table, read_concurrency: true])
:ets.new(@category_cache, [:set, :public, :named_table, read_concurrency: true])
# Load data
load_cash_data()
{:ok, %{}}
end
@impl true
def handle_call(:reload, _from, state) do
Logger.info("Reloading cash shop data...")
load_cash_data()
{:reply, :ok, state}
end
## Private Functions
defp load_cash_data do
priv_dir = :code.priv_dir(:odinsea) |> to_string()
load_categories(Path.join(priv_dir, @categories_file))
load_items(Path.join(priv_dir, @items_file))
load_packages(Path.join(priv_dir, @packages_file))
load_modifications(Path.join(priv_dir, @mods_file))
item_count = :ets.info(@item_cache, :size)
Logger.info("Loaded #{item_count} cash shop items")
end
defp load_categories(file_path) do
case File.read(file_path) do
{:ok, content} ->
case Jason.decode(content, keys: :atoms) do
{:ok, categories} when is_list(categories) ->
Enum.each(categories, fn cat ->
id = Map.get(cat, :id)
if id, do: :ets.insert(@category_cache, {id, cat})
end)
{:error, reason} ->
Logger.warn("Failed to parse categories JSON: #{inspect(reason)}")
create_fallback_categories()
end
{:error, :enoent} ->
Logger.debug("Categories file not found: #{file_path}, using fallback")
create_fallback_categories()
{:error, reason} ->
Logger.error("Failed to read categories: #{inspect(reason)}")
create_fallback_categories()
end
end
defp load_items(file_path) do
case File.read(file_path) do
{:ok, content} ->
case Jason.decode(content, keys: :atoms) do
{:ok, items} when is_list(items) ->
Enum.each(items, fn item_data ->
item = CashItem.new(item_data)
if item.sn > 0 do
:ets.insert(@item_cache, {item.sn, item})
end
end)
{:error, reason} ->
Logger.warn("Failed to parse cash items JSON: #{inspect(reason)}")
create_fallback_items()
end
{:error, :enoent} ->
Logger.warn("Cash items file not found: #{file_path}, using fallback data")
create_fallback_items()
{:error, reason} ->
Logger.error("Failed to read cash items: #{inspect(reason)}")
create_fallback_items()
end
end
defp load_packages(file_path) do
case File.read(file_path) do
{:ok, content} ->
case Jason.decode(content, keys: :atoms) do
{:ok, packages} when is_list(packages) ->
Enum.each(packages, fn pkg ->
item_id = Map.get(pkg, :item_id)
items = Map.get(pkg, :items, [])
if item_id do
:ets.insert(@package_cache, {item_id, items})
end
end)
{:error, reason} ->
Logger.warn("Failed to parse packages JSON: #{inspect(reason)}")
end
{:error, :enoent} ->
Logger.debug("Packages file not found: #{file_path}")
{:error, reason} ->
Logger.error("Failed to read packages: #{inspect(reason)}")
end
end
defp load_modifications(file_path) do
case File.read(file_path) do
{:ok, content} ->
case Jason.decode(content, keys: :atoms) do
{:ok, mods} when is_list(mods) ->
Enum.each(mods, fn mod ->
sn = Map.get(mod, :sn)
if sn do
# Get existing item and apply modifications
case :ets.lookup(@item_cache, sn) do
[{^sn, item}] ->
modified = CashItem.apply_mods(item, mod)
:ets.insert(@item_cache, {sn, modified})
[] ->
# Create new item from modification data
item = CashItem.new(mod)
:ets.insert(@item_cache, {sn, item})
end
end
end)
{:error, reason} ->
Logger.warn("Failed to parse mods JSON: #{inspect(reason)}")
end
{:error, :enoent} ->
Logger.debug("Modifications file not found: #{file_path}")
{:error, reason} ->
Logger.error("Failed to read modifications: #{inspect(reason)}")
end
end
# Fallback data for basic testing
defp create_fallback_categories do
categories = [
%{id: 1, name: "Pets", category: 1, sub_category: 0, discount_rate: 0},
%{id: 2, name: "Pet Food", category: 2, sub_category: 0, discount_rate: 0},
%{id: 3, name: "Weapons", category: 3, sub_category: 0, discount_rate: 0},
%{id: 4, name: "Equipment", category: 4, sub_category: 0, discount_rate: 0},
%{id: 5, name: "Effects", category: 5, sub_category: 0, discount_rate: 0}
]
Enum.each(categories, fn cat ->
:ets.insert(@category_cache, {cat.id, cat})
end)
end
defp create_fallback_items do
# Basic cash items for testing
items = [
%{
sn: 1_000_000,
item_id: 5_000_000,
price: 9_000,
count: 1,
period: 90,
gender: 2,
on_sale: true
},
%{
sn: 1_000_001,
item_id: 5_000_001,
price: 9_000,
count: 1,
period: 90,
gender: 2,
on_sale: true
},
%{
sn: 1_000_002,
item_id: 5_001_000,
price: 2_400,
count: 1,
period: 0,
gender: 2,
on_sale: true
}
]
Enum.each(items, fn item_data ->
item = CashItem.new(item_data)
:ets.insert(@item_cache, {item.sn, item})
end)
end
end

View File

@@ -1,6 +1,12 @@
defmodule Odinsea.Shop.Client do
@moduledoc """
Client connection handler for the cash shop server.
Handles:
- Cash shop operations (buy, gift, wishlist, etc.)
- MTS (Maple Trading System) operations
- Coupon redemption
- Inventory management
"""
use GenServer, restart: :temporary
@@ -8,8 +14,19 @@ defmodule Odinsea.Shop.Client do
require Logger
alias Odinsea.Net.Packet.In
alias Odinsea.Net.Opcodes
alias Odinsea.Shop.{Operation, MTS, Packets}
alias Odinsea.Database.Context
defstruct [:socket, :ip, :state, :character_id, :account_id]
defstruct [
:socket,
:ip,
:state,
:character_id,
:account_id,
:character,
:account
]
def start_link(socket) do
GenServer.start_link(__MODULE__, socket)
@@ -27,7 +44,9 @@ defmodule Odinsea.Shop.Client do
ip: ip_string,
state: :connected,
character_id: nil,
account_id: nil
account_id: nil,
character: nil,
account: nil
}
send(self(), :receive)
@@ -61,6 +80,10 @@ defmodule Odinsea.Shop.Client do
:ok
end
# ==============================================================================
# Packet Handling
# ==============================================================================
defp handle_packet(data, state) do
packet = In.new(data)
@@ -75,11 +98,98 @@ defmodule Odinsea.Shop.Client do
end
end
defp dispatch_packet(_opcode, _packet, state) do
# TODO: Implement cash shop packet handlers
state
defp dispatch_packet(opcode, packet, state) do
cond do
opcode == Opcodes.cp_player_loggedin() ->
handle_migrate_in(packet, state)
opcode == Opcodes.cp_cash_shop_update() ->
# Cash shop operations
handle_cash_shop_operation(packet, state)
opcode == Opcodes.cp_mts_operation() ->
# MTS operations
handle_mts_operation(packet, state)
opcode == Opcodes.cp_alive_ack() ->
# Ping response - ignore
state
true ->
Logger.debug("Unhandled cash shop opcode: 0x#{Integer.to_string(opcode, 16)}")
state
end
end
# ==============================================================================
# Migrate In Handler
# ==============================================================================
defp handle_migrate_in(packet, state) do
{char_id, packet} = In.decode_int(packet)
{_client_ip, _packet} = In.decode_string(packet) # Skip client IP
Logger.info("Cash shop migrate in for character #{char_id}")
# Load character and account
case Context.get_character(char_id) do
nil ->
Logger.error("Character #{char_id} not found")
:gen_tcp.close(state.socket)
state
character ->
case Context.get_account(character.account_id) do
nil ->
Logger.error("Account #{character.account_id} not found")
:gen_tcp.close(state.socket)
state
account ->
# Load gifts
gifts = Context.load_gifts(character.id)
character = %{character | cash_inventory: gifts ++ (character.cash_inventory || [])}
# Send cash shop setup
setup_packet = Packets.set_cash_shop(character)
:gen_tcp.send(state.socket, setup_packet)
# Send initial update
Operation.cs_update(state.socket, character)
%{state |
state: :in_cash_shop,
character_id: char_id,
account_id: account.id,
character: character,
account: account
}
end
end
end
# ==============================================================================
# Cash Shop Operation Handler
# ==============================================================================
defp handle_cash_shop_operation(packet, state) do
# Delegate to Operation module
Operation.handle(packet, state)
end
# ==============================================================================
# MTS Operation Handler
# ==============================================================================
defp handle_mts_operation(packet, state) do
# Delegate to MTS module
MTS.handle(packet, state)
end
# ==============================================================================
# Utility Functions
# ==============================================================================
defp format_ip({a, b, c, d}) do
"#{a}.#{b}.#{c}.#{d}"
end

782
lib/odinsea/shop/mts.ex Normal file
View File

@@ -0,0 +1,782 @@
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

View File

@@ -0,0 +1,923 @@
defmodule Odinsea.Shop.Operation do
@moduledoc """
Cash Shop Operation handlers.
Implements all cash shop functionality:
- Buying items with NX/Maple Points
- Gifting items to other players
- Wish list management
- Coupon redemption
- Inventory slot expansion
- Storage slot expansion
- Character slot expansion
Ported from handling/cashshop/handler/CashShopOperation.java
"""
require Logger
alias Odinsea.Shop.{CashItem, CashItemFactory, Packets}
alias Odinsea.Game.{Inventory, InventoryType, ItemInfo}
alias Odinsea.Database.Context
alias Odinsea.Net.Packet.In
# Cash shop action codes from Java
@action_coupon 0
@action_buy 3
@action_gift 4
@action_wishlist 5
@action_expand_inv 6
@action_expand_storage 7
@action_expand_chars 8
@action_to_inv 14
@action_to_cash_inv 15
@action_buy_friendship_ring 30
@action_buy_package 32
@action_buy_quest 34
@action_redeem 45
# Error codes
@error_none 0
@error_no_coupon 0xA5
@error_used_coupon 0xA7
@error_invalid_gender 0xA6
@error_no_space 0xB1
@error_invalid_target 0xA2
@error_same_account 0xA3
@error_invalid_slot 0xA4
@error_not_enough_meso 0xB8
@error_invalid_couple 0xA1
@error_invalid_ring 0xB4
@doc """
Handles a cash shop operation packet.
"""
@spec handle(In.t(), map()) :: map()
def handle(packet, client_state) do
{action, packet} = In.decode_byte(packet)
handle_action(action, packet, client_state)
end
# Coupon code redemption
defp handle_action(@action_coupon, packet, client_state) do
packet = In.skip(packet, 2)
{code, _packet} = In.decode_string(packet)
redeem_coupon(code, client_state)
end
# Buy item
defp handle_action(@action_buy, packet, client_state) do
packet = In.skip(packet, 1)
# toCharge = 1 for NX, 2 for Maple Points
{to_charge, packet} = In.decode_int(packet)
{sn, _packet} = In.decode_int(packet)
buy_item(sn, to_charge, client_state)
end
# Gift item
defp handle_action(@action_gift, packet, client_state) do
# Skip separator string
{_sep, packet} = In.decode_string(packet)
{sn, packet} = In.decode_int(packet)
{partner_name, packet} = In.decode_string(packet)
{msg, _packet} = In.decode_string(packet)
gift_item(sn, partner_name, msg, client_state)
end
# Wish list
defp handle_action(@action_wishlist, packet, client_state) do
# Read 10 wishlist items
wishlist =
Enum.reduce(1..10, {[], packet}, fn _, {list, pkt} ->
{sn, new_pkt} = In.decode_int(pkt)
{[sn | list], new_pkt}
end)
|> elem(0)
|> Enum.reverse()
update_wishlist(wishlist, client_state)
end
# Expand inventory
defp handle_action(@action_expand_inv, packet, client_state) do
packet = In.skip(packet, 1)
{to_charge, packet} = In.decode_int(packet)
{use_coupon, packet} = In.decode_byte(packet)
if use_coupon > 0 do
{sn, _packet} = In.decode_int(packet)
expand_inventory_coupon(sn, to_charge, client_state)
else
{inv_type, _packet} = In.decode_byte(packet)
expand_inventory(inv_type, to_charge, client_state)
end
end
# Expand storage
defp handle_action(@action_expand_storage, packet, client_state) do
packet = In.skip(packet, 1)
{to_charge, packet} = In.decode_int(packet)
{coupon, _packet} = In.decode_byte(packet)
slots = if coupon > 0, do: 8, else: 4
cost = if coupon > 0, do: 8_000, else: 4_000
expand_storage(slots, cost * div(slots, 4), to_charge, client_state)
end
# Expand character slots
defp handle_action(@action_expand_chars, packet, client_state) do
packet = In.skip(packet, 1)
{to_charge, packet} = In.decode_int(packet)
{sn, _packet} = In.decode_int(packet)
expand_character_slots(sn, to_charge, client_state)
end
# Move item from cash inventory to regular inventory
defp handle_action(@action_to_inv, packet, client_state) do
{cash_id, _packet} = In.decode_long(packet)
move_to_inventory(cash_id, client_state)
end
# Move item from regular inventory to cash inventory
defp handle_action(@action_to_cash_inv, packet, client_state) do
{unique_id, packet} = In.decode_long(packet)
{inv_type, _packet} = In.decode_byte(packet)
move_to_cash_inventory(unique_id, inv_type, client_state)
end
# Buy friendship/crush ring
defp handle_action(@action_buy_friendship_ring, packet, client_state) do
{_sep, packet} = In.decode_string(packet)
{to_charge, packet} = In.decode_int(packet)
{sn, packet} = In.decode_int(packet)
{partner_name, packet} = In.decode_string(packet)
{msg, _packet} = In.decode_string(packet)
buy_ring(sn, partner_name, msg, to_charge, client_state)
end
# Buy package
defp handle_action(@action_buy_package, packet, client_state) do
packet = In.skip(packet, 1)
{to_charge, packet} = In.decode_int(packet)
{sn, _packet} = In.decode_int(packet)
buy_package(sn, to_charge, client_state)
end
# Buy quest item (with meso)
defp handle_action(@action_buy_quest, packet, client_state) do
{sn, _packet} = In.decode_int(packet)
buy_quest_item(sn, client_state)
end
# Redeem code
defp handle_action(@action_redeem, _packet, client_state) do
send_redeem_response(client_state)
end
# Unknown action
defp handle_action(action, _packet, client_state) do
Logger.warning("Unknown cash shop action: #{action}")
send_error(@error_none, client_state)
end
# ==============================================================================
# CS Update (Initial Setup)
# ==============================================================================
@doc """
Sends initial cash shop update packets.
Called when player enters the cash shop.
"""
@spec cs_update(port(), map()) :: :ok
def cs_update(socket, character) do
Packets.get_cs_inventory(socket, character)
Packets.show_nx_maple_tokens(socket, character)
Packets.enable_cs_use(socket)
:ok
end
# ==============================================================================
# Implementation Functions
# ==============================================================================
@doc """
Buys a cash item for the player.
"""
@spec buy_item(integer(), integer(), map()) :: map()
def buy_item(sn, to_charge, client_state) do
character = client_state.character
with {:ok, item} <- validate_cash_item(sn),
:ok <- check_gender(item, character),
:ok <- check_cash_inventory_space(character),
:ok <- check_blocked_item(item),
:ok <- check_cash_balance(character, to_charge, item.price) do
# Deduct NX/Maple Points
new_character = modify_cs_points(character, to_charge, -item.price)
# Create item in cash inventory
cash_item = create_cash_item(item, "")
new_character = add_to_cash_inventory(new_character, cash_item)
# Send success packet
client_state
|> Map.put(:character, new_character)
|> send_bought_item(cash_item, sn)
else
{:error, code} -> send_error(code, client_state)
end
end
@doc """
Gifts an item to another player.
"""
@spec gift_item(integer(), String.t(), String.t(), map()) :: map()
def gift_item(sn, partner_name, msg, client_state) do
character = client_state.character
with {:ok, item} <- validate_cash_item(sn),
:ok <- validate_gift_message(msg),
:ok <- check_cash_balance(character, 1, item.price),
{:ok, target} <- find_character_by_name(partner_name),
:ok <- validate_gift_target(character, target),
:ok <- check_gender(item, target) do
# Deduct NX
new_character = modify_cs_points(character, 1, -item.price)
# Create gift record
create_gift(target.id, character.name, msg, item)
# Send success packet
client_state
|> Map.put(:character, new_character)
|> send_gift_sent(item, partner_name)
else
{:error, code} -> send_error(code, client_state)
end
end
@doc """
Redeems a coupon code.
"""
@spec redeem_coupon(String.t(), map()) :: map()
def redeem_coupon(code, client_state) do
if code == "" do
send_error(@error_none, client_state)
else
# Check coupon in database
case Context.get_coupon_info(code) do
{:ok, %{used: false, type: type, value: value}} ->
# Mark coupon as used
Context.mark_coupon_used(code, client_state.character.name)
# Apply coupon reward
apply_coupon_reward(type, value, client_state)
{:ok, %{used: true}} ->
send_error(@error_used_coupon, client_state)
_ ->
send_error(@error_no_coupon, client_state)
end
end
end
@doc """
Updates the player's wishlist.
"""
@spec update_wishlist([integer()], map()) :: map()
def update_wishlist(wishlist, client_state) do
# Validate all items exist
valid_items =
Enum.filter(wishlist, fn sn ->
CashItemFactory.get_item(sn) != nil
end)
|> Enum.take(10)
|> pad_wishlist()
# Update character wishlist
new_character = %{client_state.character | wishlist: valid_items}
client_state
|> Map.put(:character, new_character)
|> send_wishlist(valid_items)
end
@doc """
Expands inventory slots.
"""
@spec expand_inventory(integer(), integer(), map()) :: map()
def expand_inventory(inv_type, to_charge, client_state) do
character = client_state.character
cost = 4_000
with :ok <- check_cash_balance(character, to_charge, cost),
{:ok, inventory_type} <- get_inventory_type(inv_type),
:ok <- check_slot_limit(character, inventory_type) do
# Deduct NX
new_character = modify_cs_points(character, to_charge, -cost)
# Add slots (max 96)
slots_to_add = min(96 - get_current_slots(new_character, inventory_type), 4)
new_character = add_inventory_slots(new_character, inventory_type, slots_to_add)
send_inventory_expanded(client_state, inventory_type, slots_to_add)
else
{:error, code} -> send_error(code, client_state)
end
end
@doc """
Expands inventory using a coupon item.
"""
@spec expand_inventory_coupon(integer(), integer(), map()) :: map()
def expand_inventory_coupon(sn, to_charge, client_state) do
character = client_state.character
with {:ok, item} <- validate_cash_item(sn),
:ok <- check_cash_balance(character, to_charge, item.price),
{:ok, inventory_type} <- get_inventory_type_from_item(item),
:ok <- check_slot_limit(character, inventory_type) do
# Deduct NX
new_character = modify_cs_points(character, to_charge, -item.price)
# Add slots
slots_to_add = min(96 - get_current_slots(new_character, inventory_type), 8)
new_character = add_inventory_slots(new_character, inventory_type, slots_to_add)
send_inventory_expanded(client_state, inventory_type, slots_to_add)
else
{:error, code} -> send_error(code, client_state)
end
end
@doc """
Expands storage slots.
"""
@spec expand_storage(integer(), integer(), integer(), map()) :: map()
def expand_storage(slots, cost, to_charge, client_state) do
character = client_state.character
current_slots = character.storage_slots || 4
max_slots = 49 - slots
if current_slots >= max_slots do
send_error(@error_invalid_slot, client_state)
else
with :ok <- check_cash_balance(character, to_charge, cost) do
# Deduct NX
new_character = modify_cs_points(character, to_charge, -cost)
# Add slots
new_slots = min(current_slots + slots, max_slots)
new_character = %{new_character | storage_slots: new_slots}
send_storage_expanded(client_state, new_slots)
else
{:error, code} -> send_error(code, client_state)
end
end
end
@doc """
Expands character slots.
"""
@spec expand_character_slots(integer(), integer(), map()) :: map()
def expand_character_slots(sn, to_charge, client_state) do
character = client_state.character
with {:ok, item} <- validate_cash_item(sn),
:ok <- check_cash_balance(character, to_charge, item.price),
true <- item.item_id == 5_430_000,
current_slots <- client_state.account.character_slots || 3,
true <- current_slots < 15 do
# Deduct NX
new_character = modify_cs_points(character, to_charge, -item.price)
# Add slot
Context.increment_character_slots(client_state.account.id)
client_state
|> Map.put(:character, new_character)
|> send_character_slots_expanded(current_slots + 1)
else
_ -> send_error(@error_none, client_state)
end
end
@doc """
Moves item from cash inventory to regular inventory.
"""
@spec move_to_inventory(integer(), map()) :: map()
def move_to_inventory(cash_id, client_state) do
character = client_state.character
with {:ok, item} <- find_cash_item(character, cash_id),
:ok <- check_inventory_space(character, item.item_id, item.quantity),
{:ok, new_character, position} <- add_to_inventory(character, item) do
# Remove from cash inventory
new_character = remove_from_cash_inventory(new_character, cash_id)
client_state
|> Map.put(:character, new_character)
|> send_moved_to_inventory(item, position)
else
{:error, code} -> send_error(code, client_state)
end
end
@doc """
Moves item from regular inventory to cash inventory.
"""
@spec move_to_cash_inventory(integer(), integer(), map()) :: map()
def move_to_cash_inventory(unique_id, inv_type, client_state) do
character = client_state.character
with {:ok, item} <- find_inventory_item(character, inv_type, unique_id),
:ok <- check_cash_inventory_space(character) do
# Remove from inventory
new_character = remove_from_inventory(character, inv_type, unique_id)
# Add to cash inventory
cash_item = %{item | position: 0}
new_character = add_to_cash_inventory(new_character, cash_item)
send_moved_to_cash_inventory(client_state, cash_item)
else
{:error, code} -> send_error(code, client_state)
end
end
@doc """
Buys a friendship/crush ring.
"""
@spec buy_ring(integer(), String.t(), String.t(), integer(), map()) :: map()
def buy_ring(sn, partner_name, msg, to_charge, client_state) do
character = client_state.character
with {:ok, item} <- validate_cash_item(sn),
:ok <- validate_gift_message(msg),
:ok <- check_gender(item, character),
:ok <- check_cash_inventory_space(character),
:ok <- check_cash_balance(character, to_charge, item.price),
{:ok, target} <- find_character_by_name(partner_name),
:ok <- validate_ring_target(character, target) do
# Create ring (simplified - would need proper ring creation)
# Deduct NX
new_character = modify_cs_points(character, to_charge, -item.price)
client_state
|> Map.put(:character, new_character)
|> send_ring_purchased(item, partner_name)
else
{:error, code} -> send_error(code, client_state)
end
end
@doc """
Buys a package (contains multiple items).
"""
@spec buy_package(integer(), integer(), map()) :: map()
def buy_package(sn, to_charge, client_state) do
character = client_state.character
with {:ok, item} <- validate_cash_item(sn),
{:ok, package_items} <- get_package_items(item.item_id),
:ok <- check_gender(item, character),
:ok <- check_cash_inventory_space_for_package(character, length(package_items)),
:ok <- check_cash_balance(character, to_charge, item.price) do
# Deduct NX
new_character = modify_cs_points(character, to_charge, -item.price)
# Add all package items to inventory
{new_character, items_added} =
Enum.reduce(package_items, {new_character, []}, fn pkg_sn, {char, list} ->
case CashItemFactory.get_simple_item(pkg_sn) do
nil ->
{char, list}
pkg_item ->
cash_item = create_cash_item(pkg_item, "")
char = add_to_cash_inventory(char, cash_item)
{char, [cash_item | list]}
end
end)
client_state
|> Map.put(:character, new_character)
|> send_package_purchased(items_added)
else
{:error, code} -> send_error(code, client_state)
end
end
@doc """
Buys a quest item with meso.
"""
@spec buy_quest_item(integer(), map()) :: map()
def buy_quest_item(sn, client_state) do
character = client_state.character
with {:ok, item} <- validate_cash_item(sn),
true <- ItemInfo.is_quest?(item.item_id),
:ok <- check_meso_balance(character, item.price),
:ok <- check_inventory_space(character, item.item_id, item.count) do
# Deduct meso
new_character = %{character | meso: character.meso - item.price}
# Add item
{:ok, new_character, position} = add_item_to_inventory(new_character, item)
client_state
|> Map.put(:character, new_character)
|> send_quest_item_purchased(item, position)
else
_ -> send_error(@error_none, client_state)
end
end
# ==============================================================================
# Helper Functions
# ==============================================================================
defp validate_cash_item(sn) do
case CashItemFactory.get_item(sn) do
nil -> {:error, @error_none}
item -> {:ok, item}
end
end
defp check_gender(item, character) do
if CashItem.gender_matches?(item, character.gender) do
:ok
else
{:error, @error_invalid_gender}
end
end
defp check_cash_inventory_space(character) do
cash_items = character.cash_inventory || []
if length(cash_items) >= 100 do
{:error, @error_no_space}
else
:ok
end
end
defp check_cash_inventory_space_for_package(character, count) do
cash_items = character.cash_inventory || []
if length(cash_items) + count > 100 do
{:error, @error_no_space}
else
:ok
end
end
defp check_blocked_item(item) do
if CashItemFactory.blocked?(item.item_id) do
{:error, @error_none}
else
:ok
end
end
defp check_cash_balance(character, type, amount) do
balance = if type == 1, do: character.nx_cash || 0, else: character.maple_points || 0
if balance >= amount do
:ok
else
{:error, @error_none}
end
end
defp check_meso_balance(character, amount) do
if character.meso >= amount do
:ok
else
{:error, @error_not_enough_meso}
end
end
@doc """
Checks if there's space in inventory for an item.
"""
@spec check_inventory_space(map(), integer(), integer()) :: :ok | {:error, integer()}
def check_inventory_space(character, item_id, quantity) do
inv_type = InventoryType.from_item_id(item_id)
if Inventory.has_space?(character.inventories[inv_type], item_id, quantity) do
:ok
else
{:error, @error_no_space}
end
end
defp check_slot_limit(character, inventory_type) do
slots = get_current_slots(character, inventory_type)
if slots >= 96 do
{:error, @error_invalid_slot}
else
:ok
end
end
defp validate_gift_message(msg) do
if String.length(msg) > 73 || msg == "" do
{:error, @error_none}
else
:ok
end
end
defp find_character_by_name(name) do
case Context.get_character_by_name(name) do
nil -> {:error, @error_invalid_target}
character -> {:ok, character}
end
end
defp validate_gift_target(character, target) do
cond do
target.id == character.id -> {:error, @error_invalid_target}
target.account_id == character.account_id -> {:error, @error_same_account}
true -> :ok
end
end
defp validate_ring_target(character, target) do
cond do
target.id == character.id -> {:error, @error_invalid_ring}
target.account_id == character.account_id -> {:error, @error_same_account}
true -> :ok
end
end
defp get_package_items(item_id) do
case CashItemFactory.get_package_items(item_id) do
nil -> {:error, @error_none}
items -> {:ok, items}
end
end
defp create_gift(recipient_id, from, msg, item) do
Context.create_gift(%{
recipient_id: recipient_id,
from: from,
message: msg,
sn: item.sn,
unique_id: generate_unique_id()
})
end
defp create_cash_item(cash_item_info, gift_from) do
%{
unique_id: generate_unique_id(),
item_id: cash_item_info.item_id,
quantity: cash_item_info.count,
expiration: CashItem.expiration_time(cash_item_info),
gift_from: gift_from,
sn: cash_item_info.sn
}
end
defp modify_cs_points(character, type, amount) do
if type == 1 do
%{character | nx_cash: (character.nx_cash || 0) + amount}
else
%{character | maple_points: (character.maple_points || 0) + amount}
end
end
defp add_to_cash_inventory(character, item) do
cash_inv = character.cash_inventory || []
%{character | cash_inventory: [item | cash_inv]}
end
defp remove_from_cash_inventory(character, cash_id) do
cash_inv =
Enum.reject(character.cash_inventory || [], fn item ->
item.unique_id == cash_id
end)
%{character | cash_inventory: cash_inv}
end
defp find_cash_item(character, cash_id) do
case Enum.find(character.cash_inventory || [], &(&1.unique_id == cash_id)) do
nil -> {:error, @error_none}
item -> {:ok, item}
end
end
defp get_inventory_type(inv_type) do
case inv_type do
1 -> {:ok, :equip}
2 -> {:ok, :use}
3 -> {:ok, :setup}
4 -> {:ok, :etc}
_ -> {:error, @error_invalid_slot}
end
end
defp get_inventory_type_from_item(item) do
type = div(item.item_id, 1000)
case type do
9111 -> {:ok, :equip}
9112 -> {:ok, :use}
9113 -> {:ok, :setup}
9114 -> {:ok, :etc}
_ -> {:error, @error_invalid_slot}
end
end
defp get_current_slots(character, inventory_type) do
case character.inventory_limits[inventory_type] do
nil -> 24
limit -> limit
end
end
defp add_inventory_slots(character, inventory_type, slots) do
current = get_current_slots(character, inventory_type)
new_limits = Map.put(character.inventory_limits || %{}, inventory_type, current + slots)
%{character | inventory_limits: new_limits}
end
defp find_inventory_item(character, inv_type, unique_id) do
inventory = Map.get(character.inventories, inv_type, [])
case Enum.find(inventory, &(&1.unique_id == unique_id)) do
nil -> {:error, @error_none}
item -> {:ok, item}
end
end
defp remove_from_inventory(character, inv_type, unique_id) do
inventory = Map.get(character.inventories, inv_type, [])
new_inventory = Enum.reject(inventory, &(&1.unique_id == unique_id))
inventories = Map.put(character.inventories, inv_type, new_inventory)
%{character | inventories: inventories}
end
defp add_to_inventory(character, item) do
inv_type = InventoryType.from_item_id(item.item_id)
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}
end
defp add_item_to_inventory(character, item) do
inv_type = InventoryType.from_item_id(item.item_id)
inventory = Map.get(character.inventories, inv_type, [])
position = Inventory.next_free_slot(inventory)
new_item = %{
unique_id: generate_unique_id(),
item_id: item.item_id,
position: position,
quantity: item.count
}
new_inventory = [new_item | inventory]
inventories = Map.put(character.inventories, inv_type, new_inventory)
{:ok, %{character | inventories: inventories}, position}
end
defp apply_coupon_reward(type, value, client_state) do
character = client_state.character
{new_character, items, maple_points, mesos} =
case type do
1 ->
# NX Cash
{modify_cs_points(character, 1, value), %{}, value, 0}
2 ->
# Maple Points
{modify_cs_points(character, 2, value), %{}, value, 0}
3 ->
# Item
case CashItemFactory.get_item(value) do
nil ->
{character, %{}, 0, 0}
item ->
cash_item = create_cash_item(item, "")
new_char = add_to_cash_inventory(character, cash_item)
{new_char, %{value => cash_item}, 0, 0}
end
4 ->
# Mesos
{%{character | meso: character.meso + value}, %{}, 0, value}
_ ->
{character, %{}, 0, 0}
end
client_state
|> Map.put(:character, new_character)
|> send_coupon_redeemed(items, maple_points, mesos)
end
defp pad_wishlist(list) do
padding = List.duplicate(0, 10 - length(list))
list ++ padding
end
defp generate_unique_id do
:erlang.unique_integer([:positive])
end
# ==============================================================================
# Packet Senders (delegated to Packets module)
# ==============================================================================
defp send_error(code, client_state) do
Odinsea.Shop.Packets.send_cs_fail(client_state.socket, code)
client_state
end
defp send_bought_item(client_state, item, sn) do
Odinsea.Shop.Packets.show_bought_cs_item(client_state.socket, item, sn, client_state.account_id)
client_state
end
defp send_gift_sent(client_state, item, partner) do
Odinsea.Shop.Packets.send_gift(client_state.socket, item.price, item.item_id, item.count, partner)
client_state
end
defp send_wishlist(client_state, wishlist) do
Odinsea.Shop.Packets.send_wishlist(client_state.socket, client_state.character, wishlist)
client_state
end
defp send_inventory_expanded(client_state, inv_type, slots) do
# Send appropriate packet
Odinsea.Shop.Packets.enable_cs_use(client_state.socket)
client_state
end
defp send_storage_expanded(client_state, slots) do
# Send appropriate packet
Odinsea.Shop.Packets.enable_cs_use(client_state.socket)
client_state
end
defp send_character_slots_expanded(client_state, slots) do
Odinsea.Shop.Packets.enable_cs_use(client_state.socket)
client_state
end
defp send_moved_to_inventory(client_state, item, position) do
Odinsea.Shop.Packets.confirm_from_cs_inventory(client_state.socket, item, position)
client_state
end
defp send_moved_to_cash_inventory(client_state, item) do
Odinsea.Shop.Packets.confirm_to_cs_inventory(client_state.socket, item, client_state.account_id)
client_state
end
defp send_ring_purchased(client_state, item, partner) do
Odinsea.Shop.Packets.send_gift(client_state.socket, item.price, item.item_id, item.count, partner)
client_state
end
defp send_package_purchased(client_state, items) do
Odinsea.Shop.Packets.show_bought_cs_package(client_state.socket, items, client_state.account_id)
client_state
end
defp send_quest_item_purchased(client_state, item, position) do
Odinsea.Shop.Packets.show_bought_cs_quest_item(client_state.socket, item, position)
client_state
end
defp send_coupon_redeemed(client_state, items, maple_points, mesos) do
Odinsea.Shop.Packets.show_coupon_redeemed(client_state.socket, items, maple_points, mesos, client_state)
client_state
end
defp send_redeem_response(client_state) do
Odinsea.Shop.Packets.redeem_response(client_state.socket)
client_state
end
end

711
lib/odinsea/shop/packets.ex Normal file
View File

@@ -0,0 +1,711 @@
defmodule Odinsea.Shop.Packets do
@moduledoc """
Cash Shop and MTS packet builders.
Ported from Java tools.packet.MTSCSPacket
"""
alias Odinsea.Net.Packet.Out
alias Odinsea.Net.Opcodes
alias Odinsea.Shop.CashItem
# Cash shop operation codes for responses
@cs_success 0x00
@cs_fail 0x01
# ==============================================================================
# Cash Shop Entry/Setup Packets
# ==============================================================================
@doc """
Sets up the cash shop for a player.
Sent when player enters the cash shop.
"""
def set_cash_shop(character) do
Out.new(Opcodes.lp_set_cash_shop())
|> encode_cash_shop_info(character)
|> Out.to_data()
end
@doc """
Encodes the full cash shop info structure.
"""
defp encode_cash_shop_info(packet, character) do
# Best items (featured items)
best_items = Odinsea.Shop.CashItemFactory.get_best_items()
packet
# encodeStock
|> encode_stock()
# encodeCategory
|> encode_categories()
# encodeBest
|> encode_best_items(best_items)
# encodeGateway
|> encode_gateway()
# encodeLimitGoods
|> encode_limit_goods()
# encodeZeroGoods
|> encode_zero_goods()
# encodeCategoryInfo
|> encode_category_info()
# Character info
|> encode_character_cash_info(character)
end
defp encode_stock(packet) do
# Stock counts - simplified, no limited stock items
Out.encode_short(packet, 0)
end
defp encode_categories(packet) do
categories = Odinsea.Shop.CashItemFactory.get_categories()
packet
|> Out.encode_short(length(categories))
|> then(fn pkt ->
Enum.reduce(categories, pkt, fn cat, p ->
p
|> Out.encode_byte(cat.category)
|> Out.encode_byte(cat.sub_category)
|> Out.encode_byte(cat.discount_rate)
end)
end)
end
defp encode_best_items(packet, items) do
# Best items for each category/gender combination
packet
|> Out.encode_short(5) # Category count
|> Out.encode_short(2) # Gender count (male/female)
|> Out.encode_short(1) # Items per cell
|> then(fn pkt ->
Enum.reduce(0..4, pkt, fn _i, p1 ->
Enum.reduce(0..1, p1, fn _j, p2 ->
# Featured item for this category/gender
item_sn = Enum.random(items) || 0
p2
|> Out.encode_int(item_sn)
|> Out.encode_short(10000) # Category SN
end)
end)
end)
end
defp encode_gateway(packet) do
# Gateway info - empty for now
Out.encode_byte(packet, 0)
end
defp encode_limit_goods(packet) do
# Limited goods - empty
Out.encode_short(packet, 0)
end
defp encode_zero_goods(packet) do
# Zero goods - empty
Out.encode_short(packet, 0)
end
defp encode_category_info(packet) do
# Category parent info
Out.encode_byte(packet, 0)
end
defp encode_character_cash_info(packet, character) do
packet
|> Out.encode_int(character.nx_cash || 0)
|> Out.encode_int(character.maple_points || 0)
|> Out.encode_int(character.id)
# Gift token - not implemented
|> Out.encode_int(0)
end
@doc """
Enables cash shop usage.
"""
def enable_cs_use(socket) do
packet =
Out.new(Opcodes.lp_cash_shop_update())
|> encode_cs_update()
|> Out.to_data()
:gen_tcp.send(socket, packet)
end
defp encode_cs_update(packet) do
# Flag indicating update type
Out.encode_byte(packet, @cs_success)
end
@doc """
Sends the player's cash inventory.
"""
def get_cs_inventory(socket, character) do
cash_items = character.cash_inventory || []
packet =
Out.new(Opcodes.lp_cash_shop_update())
|> Out.encode_byte(0x4A) # Operation code for inventory
|> encode_cash_items(cash_items)
|> Out.to_data()
:gen_tcp.send(socket, packet)
end
defp encode_cash_items(packet, items) do
packet
|> Out.encode_short(length(items))
|> then(fn pkt ->
Enum.reduce(items, pkt, fn item, p ->
encode_cash_item(p, item)
end)
end)
end
defp encode_cash_item(packet, item) do
packet
|> Out.encode_long(item.unique_id)
|> Out.encode_int(item.item_id)
|> Out.encode_int(item.sn || 0)
|> Out.encode_short(item.quantity)
|> Out.encode_string(item.gift_from || "")
|> Out.encode_long(item.expiration || -1)
end
@doc """
Shows NX and Maple Point balances.
"""
def show_nx_maple_tokens(socket, character) do
packet =
Out.new(Opcodes.lp_cash_shop_update())
|> Out.encode_byte(0x3D) # Operation code for balance
|> Out.encode_int(character.nx_cash || 0)
|> Out.encode_int(character.maple_points || 0)
|> Out.to_data()
:gen_tcp.send(socket, packet)
end
# ==============================================================================
# Purchase Response Packets
# ==============================================================================
@doc """
Shows a successfully purchased cash item.
"""
def show_bought_cs_item(socket, item, sn, account_id) do
packet =
Out.new(Opcodes.lp_cash_shop_update())
|> Out.encode_byte(0x53) # Bought item operation
|> Out.encode_int(account_id)
|> encode_bought_item(item, sn)
|> Out.to_data()
:gen_tcp.send(socket, packet)
end
defp encode_bought_item(packet, item, sn) do
packet
|> Out.encode_long(item.unique_id)
|> Out.encode_int(item.item_id)
|> Out.encode_int(sn)
|> Out.encode_short(item.quantity)
|> Out.encode_string(item.gift_from || "")
|> Out.encode_long(item.expiration || -1)
end
@doc """
Shows a successfully purchased package.
"""
def show_bought_cs_package(socket, items, account_id) do
packet =
Out.new(Opcodes.lp_cash_shop_update())
|> Out.encode_byte(0x5D) # Package operation
|> Out.encode_int(account_id)
|> Out.encode_short(length(items))
|> then(fn pkt ->
Enum.reduce(items, pkt, fn item, p ->
encode_bought_item(p, item, item.sn)
end)
end)
|> Out.to_data()
:gen_tcp.send(socket, packet)
end
@doc """
Shows a successfully purchased quest item.
"""
def show_bought_cs_quest_item(socket, item, position) do
packet =
Out.new(Opcodes.lp_cash_shop_update())
|> Out.encode_byte(0x73) # Quest item operation
|> Out.encode_int(item.price)
|> Out.encode_short(item.count)
|> Out.encode_short(position)
|> Out.encode_int(item.item_id)
|> Out.to_data()
:gen_tcp.send(socket, packet)
end
@doc """
Confirms item moved from cash inventory to regular inventory.
"""
def confirm_from_cs_inventory(socket, item, position) do
packet =
Out.new(Opcodes.lp_cash_shop_update())
|> Out.encode_byte(0x69) # From CS inventory
|> Out.encode_byte(Odinsea.Game.InventoryType.get_type(
Odinsea.Game.InventoryType.from_item_id(item.item_id)
))
|> Out.encode_short(position)
|> Out.to_data()
:gen_tcp.send(socket, packet)
end
@doc """
Confirms item moved to cash inventory.
"""
def confirm_to_cs_inventory(socket, item, account_id) do
packet =
Out.new(Opcodes.lp_cash_shop_update())
|> Out.encode_byte(0x5F) # To CS inventory
|> Out.encode_int(account_id)
|> encode_cash_item_single(item)
|> Out.to_data()
:gen_tcp.send(socket, packet)
end
defp encode_cash_item_single(packet, item) do
packet
|> Out.encode_long(item.unique_id)
|> Out.encode_int(item.item_id)
|> Out.encode_int(item.sn || 0)
|> Out.encode_short(item.quantity)
|> Out.encode_string(item.gift_from || "")
end
# ==============================================================================
# Gift Packets
# ==============================================================================
@doc """
Sends gift confirmation.
"""
def send_gift(socket, price, item_id, count, partner) do
packet =
Out.new(Opcodes.lp_cash_shop_update())
|> Out.encode_byte(0x5B) # Gift sent operation
|> Out.encode_int(price)
|> Out.encode_int(item_id)
|> Out.encode_short(count)
|> Out.encode_string(partner)
|> Out.to_data()
:gen_tcp.send(socket, packet)
end
@doc """
Gets gifts for the player.
"""
def get_cs_gifts(socket, gifts) do
packet =
Out.new(Opcodes.lp_cash_shop_update())
|> Out.encode_byte(0x48) # Gifts operation
|> Out.encode_short(length(gifts))
|> then(fn pkt ->
Enum.reduce(gifts, pkt, fn {item, msg}, p ->
p
|> Out.encode_int(item.sn)
|> Out.encode_string(item.gift_from)
|> Out.encode_string(msg)
|> encode_cash_item_single(item)
end)
end)
|> Out.to_data()
:gen_tcp.send(socket, packet)
end
# ==============================================================================
# Wishlist Packets
# ==============================================================================
@doc """
Sends the wishlist to the player.
"""
def send_wishlist(socket, _character, wishlist) do
packet =
Out.new(Opcodes.lp_cash_shop_update())
|> Out.encode_byte(0x4D) # Wishlist operation
|> then(fn pkt ->
Enum.reduce(wishlist, pkt, fn sn, p ->
Out.encode_int(p, sn)
end)
end)
|> Out.to_data()
:gen_tcp.send(socket, packet)
end
# ==============================================================================
# Coupon Packets
# ==============================================================================
@doc """
Shows coupon redemption result.
"""
def show_coupon_redeemed(socket, items, maple_points, mesos, client_state) do
packet =
Out.new(Opcodes.lp_cash_shop_update())
|> Out.encode_byte(0x4B) # Coupon operation
|> Out.encode_byte(if safe_map_size(items) > 0, do: 1, else: 0)
|> Out.encode_int(safe_map_size(items))
|> then(fn pkt ->
Enum.reduce(items, pkt, fn {_sn, item}, p ->
encode_coupon_item(p, item, client_state)
end)
end)
|> Out.encode_int(mesos)
|> Out.encode_int(maple_points)
|> Out.to_data()
:gen_tcp.send(socket, packet)
end
defp encode_coupon_item(packet, item, _client_state) do
packet
|> Out.encode_int(item.sn)
|> Out.encode_byte(0) # Unknown
|> Out.encode_int(item.item_id)
|> Out.encode_int(item.count)
end
@doc """
Redeem response (simplified).
"""
def redeem_response(socket) do
packet =
Out.new(Opcodes.lp_cash_shop_update())
|> Out.encode_byte(0xA1) # Redeem response
|> Out.encode_int(0)
|> Out.encode_int(0)
|> Out.encode_int(0)
|> Out.to_data()
:gen_tcp.send(socket, packet)
end
# ==============================================================================
# Error Packets
# ==============================================================================
@doc """
Sends a cash shop failure code.
"""
def send_cs_fail(socket, code) do
packet =
Out.new(Opcodes.lp_cash_shop_update())
|> Out.encode_byte(0x49) # Fail operation
|> Out.encode_byte(code)
|> Out.to_data()
:gen_tcp.send(socket, packet)
end
@doc """
Notifies that a cash item has expired.
"""
def cash_item_expired(socket, unique_id) do
packet =
Out.new(Opcodes.lp_cash_shop_update())
|> Out.encode_byte(0x4E) # Expired operation
|> Out.encode_long(unique_id)
|> Out.to_data()
:gen_tcp.send(socket, packet)
end
# ==============================================================================
# MTS Packets
# ==============================================================================
@doc """
Starts the MTS for a player.
"""
def start_mts(character) do
Out.new(Opcodes.lp_set_mts_opened())
|> encode_mts_info(character)
|> Out.to_data()
end
defp encode_mts_info(packet, character) do
packet
|> encode_mts_tax_rates()
|> encode_character_mts_info(character)
end
defp encode_mts_tax_rates(packet) do
# Tax rates for different price ranges
packet
|> Out.encode_int(5) # Number of brackets
# Bracket 1: 0-5000000
|> Out.encode_int(0)
|> Out.encode_int(5_000_000)
|> Out.encode_int(10) # 10% tax
# Bracket 2: 5000000-10000000
|> Out.encode_int(5_000_001)
|> Out.encode_int(10_000_000)
|> Out.encode_int(9)
# Bracket 3: 10000000-50000000
|> Out.encode_int(10_000_001)
|> Out.encode_int(50_000_000)
|> Out.encode_int(8)
# Bracket 4: 50000000-100000000
|> Out.encode_int(50_000_001)
|> Out.encode_int(100_000_000)
|> Out.encode_int(7)
# Bracket 5: 100000000+
|> Out.encode_int(100_000_001)
|> Out.encode_int(999_999_999)
|> Out.encode_int(6)
end
defp encode_character_mts_info(packet, character) do
packet
|> Out.encode_int(character.nx_cash || 0)
|> Out.encode_int(character.maple_points || 0)
|> Out.encode_int(character.id)
end
@doc """
Shows MTS cash balance.
"""
def show_mts_cash(socket, character) do
packet =
Out.new(Opcodes.lp_mts_operation())
|> Out.encode_byte(0x17) # Show cash
|> Out.encode_int(character.nx_cash || 0)
|> Out.encode_int(character.maple_points || 0)
|> Out.to_data()
:gen_tcp.send(socket, packet)
end
@doc """
Sends current MTS listings.
"""
def send_current_mts(socket, cart) do
items = Odinsea.Shop.MTS.get_current_mts(cart)
packet =
Out.new(Opcodes.lp_mts_operation())
|> Out.encode_byte(0x16) # Current MTS
|> Out.encode_int(length(items))
|> Out.encode_int(0) # Total count (for pagination)
|> Out.encode_int(cart.page)
|> Out.encode_int(cart.tab)
|> then(fn pkt ->
Enum.reduce(items, pkt, fn item, p ->
encode_mts_item(p, item)
end)
end)
|> Out.to_data()
:gen_tcp.send(socket, packet)
end
@doc """
Sends "not yet sold" listings.
"""
def send_not_yet_sold(socket, cart) do
items = Odinsea.Shop.MTS.get_not_yet_sold(cart.character_id)
packet =
Out.new(Opcodes.lp_mts_operation())
|> Out.encode_byte(0x18) # Not yet sold
|> Out.encode_int(length(items))
|> then(fn pkt ->
Enum.reduce(items, pkt, fn item, p ->
encode_mts_item(p, item)
end)
end)
|> Out.to_data()
:gen_tcp.send(socket, packet)
end
@doc """
Sends transfer inventory.
"""
def send_transfer(socket, cart, changed \\ false) do
items = Odinsea.Shop.MTS.get_transfer(cart.character_id)
packet =
Out.new(Opcodes.lp_mts_operation())
|> Out.encode_byte(0x19) # Transfer
|> Out.encode_byte(if changed, do: 1, else: 0)
|> Out.encode_int(length(items))
|> then(fn pkt ->
Enum.reduce(items, pkt, fn item, p ->
encode_mts_transfer_item(p, item)
end)
end)
|> Out.to_data()
:gen_tcp.send(socket, packet)
end
defp encode_mts_item(packet, item) do
packet
|> Out.encode_int(item.id)
|> Out.encode_int(item.item.item_id)
|> Out.encode_int(item.price)
|> Out.encode_int(item.price) # Current price (can change)
|> Out.encode_int(item.seller_id)
|> Out.encode_string(item.seller_name)
|> Out.encode_long(item.expiration)
|> encode_item_stats(item.item)
end
defp encode_mts_transfer_item(packet, item) do
packet
|> Out.encode_int(item.item_id)
|> Out.encode_short(item.quantity)
|> encode_item_stats(item)
end
defp encode_item_stats(packet, item) do
# Full item encoding with stats
# This is simplified - full version would encode equipment stats
packet
|> Out.encode_byte(0) # Has stats flag
end
@doc """
MTS wanted listing over.
"""
def get_mts_wanted_listing_over(socket, nx, maple_points) do
packet =
Out.new(Opcodes.lp_mts_operation())
|> Out.encode_byte(0x1A)
|> Out.encode_int(nx)
|> Out.encode_int(maple_points)
|> Out.to_data()
:gen_tcp.send(socket, packet)
end
@doc """
MTS confirm sell.
"""
def get_mts_confirm_sell(socket) do
packet =
Out.new(Opcodes.lp_mts_operation())
|> Out.encode_byte(0x02)
|> Out.to_data()
:gen_tcp.send(socket, packet)
end
@doc """
MTS fail sell.
"""
def get_mts_fail_sell(socket) do
packet =
Out.new(Opcodes.lp_mts_operation())
|> Out.encode_byte(0x03)
|> Out.encode_byte(0) # Error code
|> Out.to_data()
:gen_tcp.send(socket, packet)
end
@doc """
MTS confirm cancel.
"""
def get_mts_confirm_cancel(socket) do
packet =
Out.new(Opcodes.lp_mts_operation())
|> Out.encode_byte(0x08)
|> Out.to_data()
:gen_tcp.send(socket, packet)
end
@doc """
MTS fail cancel.
"""
def get_mts_fail_cancel(socket) do
packet =
Out.new(Opcodes.lp_mts_operation())
|> Out.encode_byte(0x09)
|> Out.to_data()
:gen_tcp.send(socket, packet)
end
@doc """
MTS confirm buy.
"""
def get_mts_confirm_buy(socket) do
packet =
Out.new(Opcodes.lp_mts_operation())
|> Out.encode_byte(0x0C)
|> Out.to_data()
:gen_tcp.send(socket, packet)
end
@doc """
MTS fail buy.
"""
def get_mts_fail_buy(socket) do
packet =
Out.new(Opcodes.lp_mts_operation())
|> Out.encode_byte(0x0D)
|> Out.encode_byte(0)
|> Out.to_data()
:gen_tcp.send(socket, packet)
end
@doc """
MTS confirm transfer.
"""
def get_mts_confirm_transfer(socket, inv_type, position) do
packet =
Out.new(Opcodes.lp_mts_operation())
|> Out.encode_byte(0x11)
|> Out.encode_byte(inv_type)
|> Out.encode_short(position)
|> Out.to_data()
:gen_tcp.send(socket, packet)
end
@doc """
Add to cart message.
"""
def add_to_cart_message(socket, failed, deleted) do
packet =
Out.new(Opcodes.lp_mts_operation())
|> Out.encode_byte(0x15)
|> Out.encode_byte(if failed, do: 0, else: 1)
|> Out.encode_byte(if deleted, do: 0, else: 1)
|> Out.to_data()
:gen_tcp.send(socket, packet)
end
# ==============================================================================
# Utility Functions
# ==============================================================================
defp safe_map_size(map) when is_map(map), do: Kernel.map_size(map)
defp safe_map_size(_), do: 0
end