defmodule Odinsea.Game.Quest do @moduledoc """ Quest Information Provider - loads and caches quest data. This module loads quest metadata, requirements, and actions from cached JSON files. The JSON files should be exported from the Java server's WZ data providers. Data is cached in ETS for fast lookups. ## Quest Structure A quest consists of: - **ID**: Unique quest identifier - **Name**: Quest display name - **Start Requirements**: Conditions to start the quest (level, items, completed quests, etc.) - **Complete Requirements**: Conditions to complete the quest (mob kills, items, etc.) - **Start Actions**: Rewards/actions when starting the quest - **Complete Actions**: Rewards/actions when completing the quest (exp, meso, items, etc.) ## Quest Flags - `auto_start`: Quest starts automatically when requirements are met - `auto_complete`: Quest completes automatically when requirements are met - `auto_pre_complete`: Auto-complete without NPC interaction - `repeatable`: Quest can be repeated - `blocked`: Quest is disabled/blocked - `has_no_npc`: Quest has no associated NPC - `option`: Quest has multiple start options - `custom_end`: Quest has a custom end script - `scripted_start`: Quest has a custom start script """ use GenServer require Logger alias Odinsea.Game.{QuestRequirement, QuestAction} # ETS table names @quest_cache :odinsea_quest_cache @quest_names :odinsea_quest_names # Data file paths (relative to priv directory) @quest_data_file "data/quests.json" @quest_strings_file "data/quest_strings.json" defmodule QuestInfo do @moduledoc "Complete quest information structure" @type t :: %__MODULE__{ quest_id: integer(), name: String.t(), start_requirements: [Odinsea.Game.QuestRequirement.t()], complete_requirements: [Odinsea.Game.QuestRequirement.t()], start_actions: [Odinsea.Game.QuestAction.t()], complete_actions: [Odinsea.Game.QuestAction.t()], auto_start: boolean(), auto_complete: boolean(), auto_pre_complete: boolean(), repeatable: boolean(), blocked: boolean(), has_no_npc: boolean(), option: boolean(), custom_end: boolean(), scripted_start: boolean(), view_medal_item: integer(), selected_skill_id: integer(), relevant_mobs: %{integer() => integer()} } defstruct [ :quest_id, :name, start_requirements: [], complete_requirements: [], start_actions: [], complete_actions: [], auto_start: false, auto_complete: false, auto_pre_complete: false, repeatable: false, blocked: false, has_no_npc: true, option: false, custom_end: false, scripted_start: false, view_medal_item: 0, selected_skill_id: 0, relevant_mobs: %{} ] end ## Public API @doc "Starts the Quest GenServer" def start_link(opts \\ []) do GenServer.start_link(__MODULE__, opts, name: __MODULE__) end @doc "Gets quest information by quest ID" @spec get_quest(integer()) :: QuestInfo.t() | nil def get_quest(quest_id) do case :ets.lookup(@quest_cache, quest_id) do [{^quest_id, quest}] -> quest [] -> nil end end @doc "Gets quest name by quest ID" @spec get_name(integer()) :: String.t() | nil def get_name(quest_id) do case :ets.lookup(@quest_names, quest_id) do [{^quest_id, name}] -> name [] -> "UNKNOWN" end end @doc "Gets all loaded quest IDs" @spec get_all_quest_ids() :: [integer()] def get_all_quest_ids do :ets.select(@quest_cache, [{{:"$1", :_}, [], [:"$1"]}]) end @doc "Checks if a quest exists" @spec quest_exists?(integer()) :: boolean() def quest_exists?(quest_id) do :ets.member(@quest_cache, quest_id) end @doc "Gets start requirements for a quest" @spec get_start_requirements(integer()) :: [Odinsea.Game.QuestRequirement.t()] def get_start_requirements(quest_id) do case get_quest(quest_id) do nil -> [] quest -> quest.start_requirements end end @doc "Gets complete requirements for a quest" @spec get_complete_requirements(integer()) :: [Odinsea.Game.QuestRequirement.t()] def get_complete_requirements(quest_id) do case get_quest(quest_id) do nil -> [] quest -> quest.complete_requirements end end @doc "Gets complete actions (rewards) for a quest" @spec get_complete_actions(integer()) :: [Odinsea.Game.QuestAction.t()] def get_complete_actions(quest_id) do case get_quest(quest_id) do nil -> [] quest -> quest.complete_actions end end @doc "Gets relevant mobs for a quest (mob_id => count_required)" @spec get_relevant_mobs(integer()) :: %{integer() => integer()} def get_relevant_mobs(quest_id) do case get_quest(quest_id) do nil -> %{} quest -> quest.relevant_mobs end end @doc "Checks if quest can be started by a character" @spec can_start?(integer(), Odinsea.Game.Character.t()) :: boolean() def can_start?(quest_id, character) do case get_quest(quest_id) do nil -> false quest -> check_requirements(quest.start_requirements, character) end end @doc "Checks if quest can be completed by a character" @spec can_complete?(integer(), Odinsea.Game.Character.t()) :: boolean() def can_complete?(quest_id, character) do case get_quest(quest_id) do nil -> false quest -> check_requirements(quest.complete_requirements, character) end end @doc "Checks if a quest is auto-start" @spec auto_start?(integer()) :: boolean() def auto_start?(quest_id) do case get_quest(quest_id) do nil -> false quest -> quest.auto_start end end @doc "Checks if a quest is auto-complete" @spec auto_complete?(integer()) :: boolean() def auto_complete?(quest_id) do case get_quest(quest_id) do nil -> false quest -> quest.auto_complete end end @doc "Checks if a quest is repeatable" @spec repeatable?(integer()) :: boolean() def repeatable?(quest_id) do case get_quest(quest_id) do nil -> false quest -> quest.repeatable end end @doc "Reloads quest data from files" def reload do GenServer.call(__MODULE__, :reload, :infinity) end ## GenServer Callbacks @impl true def init(_opts) do # Create ETS tables :ets.new(@quest_cache, [:set, :public, :named_table, read_concurrency: true]) :ets.new(@quest_names, [:set, :public, :named_table, read_concurrency: true]) # Load data load_quest_data() {:ok, %{}} end @impl true def handle_call(:reload, _from, state) do Logger.info("Reloading quest data...") load_quest_data() {:reply, :ok, state} end ## Private Functions defp load_quest_data do priv_dir = :code.priv_dir(:odinsea) |> to_string() # Try to load from JSON files # If files don't exist, create minimal fallback data load_quest_strings(Path.join(priv_dir, @quest_strings_file)) load_quests(Path.join(priv_dir, @quest_data_file)) quest_count = :ets.info(@quest_cache, :size) Logger.info("Loaded #{quest_count} quest definitions") end defp load_quest_strings(file_path) do case File.read(file_path) do {:ok, content} -> case Jason.decode(content) do {:ok, data} when is_map(data) -> Enum.each(data, fn {id_str, name} -> case Integer.parse(id_str) do {quest_id, ""} -> :ets.insert(@quest_names, {quest_id, name}) _ -> :ok end end) {:error, reason} -> Logger.warn("Failed to parse quest strings JSON: #{inspect(reason)}") create_fallback_strings() end {:error, :enoent} -> Logger.warn("Quest strings file not found: #{file_path}, using fallback data") create_fallback_strings() {:error, reason} -> Logger.error("Failed to read quest strings: #{inspect(reason)}") create_fallback_strings() end end defp load_quests(file_path) do case File.read(file_path) do {:ok, content} -> case Jason.decode(content, keys: :atoms) do {:ok, quests} when is_list(quests) -> Enum.each(quests, fn quest_data -> quest = build_quest_from_json(quest_data) :ets.insert(@quest_cache, {quest.quest_id, quest}) end) {:error, reason} -> Logger.warn("Failed to parse quests JSON: #{inspect(reason)}") create_fallback_quests() end {:error, :enoent} -> Logger.warn("Quests file not found: #{file_path}, using fallback data") create_fallback_quests() {:error, reason} -> Logger.error("Failed to read quests: #{inspect(reason)}") create_fallback_quests() end end defp build_quest_from_json(data) do quest_id = Map.get(data, :quest_id, Map.get(data, :id, 0)) # Build requirements from JSON start_reqs = data |> Map.get(:start_requirements, []) |> Enum.map(&QuestRequirement.from_map/1) complete_reqs = data |> Map.get(:complete_requirements, []) |> Enum.map(&QuestRequirement.from_map/1) # Build actions from JSON start_actions = data |> Map.get(:start_actions, []) |> Enum.map(&QuestAction.from_map/1) complete_actions = data |> Map.get(:complete_actions, []) |> Enum.map(&QuestAction.from_map/1) # Extract relevant mobs from mob requirements relevant_mobs = extract_relevant_mobs(complete_reqs) %QuestInfo{ quest_id: quest_id, name: Map.get(data, :name, get_name(quest_id)), start_requirements: start_reqs, complete_requirements: complete_reqs, start_actions: start_actions, complete_actions: complete_actions, auto_start: Map.get(data, :auto_start, false), auto_complete: Map.get(data, :auto_complete, false), auto_pre_complete: Map.get(data, :auto_pre_complete, false), repeatable: Map.get(data, :repeatable, false), blocked: Map.get(data, :blocked, false), has_no_npc: Map.get(data, :has_no_npc, true), option: Map.get(data, :option, false), custom_end: Map.get(data, :custom_end, false), scripted_start: Map.get(data, :scripted_start, false), view_medal_item: Map.get(data, :view_medal_item, 0), selected_skill_id: Map.get(data, :selected_skill_id, 0), relevant_mobs: relevant_mobs } end defp extract_relevant_mobs(requirements) do requirements |> Enum.filter(fn req -> req.type == :mob end) |> Enum.flat_map(fn req -> req.data end) |> Map.new() end defp check_requirements(requirements, character) do Enum.all?(requirements, fn req -> QuestRequirement.check(req, character) end) end # Fallback data for basic testing without WZ exports defp create_fallback_strings do # Common beginner quest names fallback_names = %{ # Tutorial quests 1_000 => "[Required] The New Explorer", 1_001 => "[Required] Moving Around", 1_002 => "[Required] Attacking Enemies", 1_003 => "[Required] Quest and Journal", # Mai's quests (beginner) 2_001 => "Mai's First Request", 2_002 => "Mai's Second Request", 2_003 => "Mai's Final Request", # Job advancement quests 10_000 => "The Path of a Warrior", 10_001 => "The Path of a Magician", 10_002 => "The Path of a Bowman", 10_003 => "The Path of a Thief", 10_004 => "The Path of a Pirate", # Maple Island quests 2_006 => "The Honey Thief", 2_007 => "Delivering the Honey", 2_008 => "The Missing Child", # Victoria Island quests 2_101 => "Pio's Collecting Recycled Goods", 2_102 => "Pio's Recycling", 2_201 => "Bigg's Secret Collecting", 2_202 => "Bigg's Secret Formula", 2_203 => "The Mineral Sack", # Explorer quests 2_900 => "Explorer of the Hill", 2_901 => "Explorer of the Forest", # Medal quests 2_9005 => "Victoria Island Explorer", 2_9006 => "El Nath Explorer", 2_9014 => "Sleepywood Explorer" } Enum.each(fallback_names, fn {quest_id, name} -> :ets.insert(@quest_names, {quest_id, name}) end) end defp create_fallback_quests do # Mai's First Request - Classic beginner tutorial quest mai_first_request = %QuestInfo{ quest_id: 2_001, name: "Mai's First Request", start_requirements: [ %Odinsea.Game.QuestRequirement{ type: :lvmin, data: 1 } ], complete_requirements: [ %Odinsea.Game.QuestRequirement{ type: :item, data: %{2_000_001 => 1} } ], start_actions: [], complete_actions: [ %Odinsea.Game.QuestAction{ type: :exp, value: 50 }, %Odinsea.Game.QuestAction{ type: :money, value: 100 } ], auto_start: false, has_no_npc: false } # Mai's Second Request mai_second_request = %QuestInfo{ quest_id: 2_002, name: "Mai's Second Request", start_requirements: [ %Odinsea.Game.QuestRequirement{ type: :quest, data: %{2_001 => 2} }, %Odinsea.Game.QuestRequirement{ type: :lvmin, data: 1 } ], complete_requirements: [ %Odinsea.Game.QuestRequirement{ type: :mob, data: %{1_001_001 => 3} } ], start_actions: [], complete_actions: [ %Odinsea.Game.QuestAction{ type: :exp, value: 100 }, %Odinsea.Game.QuestAction{ type: :money, value: 200 }, %Odinsea.Game.QuestAction{ type: :item, value: [ %{item_id: 2_000_000, count: 20} ] } ], auto_start: false, has_no_npc: false } # Tutorial Movement Quest tutorial_movement = %QuestInfo{ quest_id: 1_001, name: "[Required] Moving Around", start_requirements: [ %Odinsea.Game.QuestRequirement{ type: :quest, data: %{1_000 => 2} } ], complete_requirements: [], start_actions: [], complete_actions: [ %Odinsea.Game.QuestAction{ type: :exp, value: 25 } ], auto_complete: true, has_no_npc: true } # Explorer quest example (Medal) explorer_victoria = %QuestInfo{ quest_id: 2_9005, name: "Victoria Island Explorer", start_requirements: [ %Odinsea.Game.QuestRequirement{ type: :lvmin, data: 15 }, %Odinsea.Game.QuestRequirement{ type: :questComplete, data: 10 } ], complete_requirements: [ %Odinsea.Game.QuestRequirement{ type: :fieldEnter, data: [100_000_000, 101_000_000, 102_000_000, 103_000_000, 104_000_000] } ], start_actions: [], complete_actions: [ %Odinsea.Game.QuestAction{ type: :exp, value: 500 }, %Odinsea.Game.QuestAction{ type: :item, value: [ %{item_id: 1_142_005, count: 1, period: 0} ] } ], has_no_npc: false } # Job advancement - Warrior warrior_path = %QuestInfo{ quest_id: 10_000, name: "The Path of a Warrior", start_requirements: [ %Odinsea.Game.QuestRequirement{ type: :lvmin, data: 10 }, %Odinsea.Game.QuestRequirement{ type: :job, data: [0] } ], complete_requirements: [ %Odinsea.Game.QuestRequirement{ type: :fieldEnter, data: [102_000_003] } ], start_actions: [], complete_actions: [ %Odinsea.Game.QuestAction{ type: :exp, value: 200 }, %Odinsea.Game.QuestAction{ type: :job, value: 100 } ], has_no_npc: false } # Store fallback quests :ets.insert(@quest_cache, {mai_first_request.quest_id, mai_first_request}) :ets.insert(@quest_cache, {mai_second_request.quest_id, mai_second_request}) :ets.insert(@quest_cache, {tutorial_movement.quest_id, tutorial_movement}) :ets.insert(@quest_cache, {explorer_victoria.quest_id, explorer_victoria}) :ets.insert(@quest_cache, {warrior_path.quest_id, warrior_path}) end end