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