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