kimi gone wild
This commit is contained in:
186
lib/odinsea/shop/cash_item.ex
Normal file
186
lib/odinsea/shop/cash_item.ex
Normal 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
|
||||
367
lib/odinsea/shop/cash_item_factory.ex
Normal file
367
lib/odinsea/shop/cash_item_factory.ex
Normal 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
|
||||
@@ -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
782
lib/odinsea/shop/mts.ex
Normal 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
|
||||
923
lib/odinsea/shop/operation.ex
Normal file
923
lib/odinsea/shop/operation.ex
Normal 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
711
lib/odinsea/shop/packets.ex
Normal 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
|
||||
Reference in New Issue
Block a user