defmodule Odinsea.Game.QuestRequirement do @moduledoc """ Quest Requirement module - defines conditions for quest start/completion. Requirements are checked when: - Starting a quest (start_requirements) - Completing a quest (complete_requirements) ## Requirement Types - `:job` - Required job class - `:item` - Required items in inventory - `:quest` - Required quest completion status - `:lvmin` - Minimum level - `:lvmax` - Maximum level - `:mob` - Required mob kills - `:npc` - NPC to talk to - `:fieldEnter` - Enter specific map(s) - `:pop` - Minimum fame - `:interval` - Time interval for repeatable quests - `:skill` - Required skill level - `:pet` - Required pet - `:mbmin` - Monster book minimum cards - `:mbcard` - Specific monster book card level - `:questComplete` - Minimum number of completed quests - `:subJobFlags` - Sub-job flags (e.g., Dual Blade) - `:pettamenessmin` - Minimum pet closeness - `:partyQuest_S` - S-rank party quest completions - `:charmMin`, `:senseMin`, `:craftMin`, `:willMin`, `:charismaMin`, `:insightMin` - Trait minimums - `:dayByDay` - Daily quest - `:normalAutoStart` - Auto-start quest - `:startscript`, `:endscript` - Custom scripts """ @type t :: %__MODULE__{ type: atom(), data: any() } defstruct [:type, :data] ## Public API @doc "Creates a new quest requirement" @spec new(atom(), any()) :: t() def new(type, data) do %__MODULE__{ type: type, data: data } end @doc "Builds a requirement from a map (JSON deserialization)" @spec from_map(map()) :: t() def from_map(map) do type = parse_type(Map.get(map, :type, Map.get(map, "type", "undefined"))) data = parse_data(type, Map.get(map, :data, Map.get(map, "data", nil))) %__MODULE__{ type: type, data: data } end @doc "Checks if a character meets this requirement" @spec check(t(), Odinsea.Game.Character.t()) :: boolean() def check(%__MODULE__{} = req, character) do do_check(req.type, req.data, character) end @doc "Parses a WZ requirement name into an atom" @spec parse_type(String.t() | atom()) :: atom() def parse_type(type) when is_atom(type), do: type def parse_type(type_str) when is_binary(type_str) do case String.downcase(type_str) do "job" -> :job "item" -> :item "quest" -> :quest "lvmin" -> :lvmin "lvmax" -> :lvmax "end" -> :end "mob" -> :mob "npc" -> :npc "fieldenter" -> :fieldEnter "interval" -> :interval "startscript" -> :startscript "endscript" -> :endscript "pet" -> :pet "pettamenessmin" -> :pettamenessmin "mbmin" -> :mbmin "questcomplete" -> :questComplete "pop" -> :pop "skill" -> :skill "mbcard" -> :mbcard "subjobflags" -> :subJobFlags "daybyday" -> :dayByDay "normalautostart" -> :normalAutoStart "partyquest_s" -> :partyQuest_S "charmmin" -> :charmMin "sensemin" -> :senseMin "craftmin" -> :craftMin "willmin" -> :willMin "charismamin" -> :charismaMin "insightmin" -> :insightMin _ -> :undefined end end ## Private Functions defp parse_data(:job, data) when is_list(data), do: data defp parse_data(:job, data) when is_integer(data), do: [data] defp parse_data(:job, data) when is_binary(data), do: [String.to_integer(data)] defp parse_data(:item, data) when is_map(data), do: data defp parse_data(:item, data) when is_list(data) do Enum.reduce(data, %{}, fn item, acc -> item_id = Map.get(item, :id, Map.get(item, "id", 0)) count = Map.get(item, :count, Map.get(item, "count", 1)) Map.put(acc, item_id, count) end) end defp parse_data(:quest, data) when is_map(data), do: data defp parse_data(:quest, data) when is_list(data) do Enum.reduce(data, %{}, fn quest, acc -> quest_id = Map.get(quest, :id, Map.get(quest, "id", 0)) state = Map.get(quest, :state, Map.get(quest, "state", 0)) Map.put(acc, quest_id, state) end) end defp parse_data(:mob, data) when is_map(data), do: data defp parse_data(:mob, data) when is_list(data) do Enum.reduce(data, %{}, fn mob, acc -> mob_id = Map.get(mob, :id, Map.get(mob, "id", 0)) count = Map.get(mob, :count, Map.get(mob, "count", 1)) Map.put(acc, mob_id, count) end) end defp parse_data(:lvmin, data) when is_integer(data), do: data defp parse_data(:lvmin, data) when is_binary(data), do: String.to_integer(data) defp parse_data(:lvmax, data) when is_integer(data), do: data defp parse_data(:lvmax, data) when is_binary(data), do: String.to_integer(data) defp parse_data(:npc, data) when is_integer(data), do: data defp parse_data(:npc, data) when is_binary(data), do: String.to_integer(data) defp parse_data(:pop, data) when is_integer(data), do: data defp parse_data(:pop, data) when is_binary(data), do: String.to_integer(data) defp parse_data(:interval, data) when is_integer(data), do: data defp parse_data(:interval, data) when is_binary(data), do: String.to_integer(data) defp parse_data(:fieldEnter, data) when is_list(data), do: data defp parse_data(:fieldEnter, data) when is_integer(data), do: [data] defp parse_data(:fieldEnter, data) when is_binary(data) do case Integer.parse(data) do {int, _} -> [int] :error -> [] end end defp parse_data(:questComplete, data) when is_integer(data), do: data defp parse_data(:questComplete, data) when is_binary(data), do: String.to_integer(data) defp parse_data(:mbmin, data) when is_integer(data), do: data defp parse_data(:mbmin, data) when is_binary(data), do: String.to_integer(data) defp parse_data(:pettamenessmin, data) when is_integer(data), do: data defp parse_data(:pettamenessmin, data) when is_binary(data), do: String.to_integer(data) defp parse_data(:subJobFlags, data) when is_integer(data), do: data defp parse_data(:subJobFlags, data) when is_binary(data), do: String.to_integer(data) defp parse_data(:skill, data) when is_list(data) do Enum.map(data, fn skill -> id = Map.get(skill, :id, Map.get(skill, "id", 0)) acquire = Map.get(skill, :acquire, Map.get(skill, "acquire", 0)) {id, acquire > 0} end) end defp parse_data(:mbcard, data) when is_list(data) do Enum.map(data, fn card -> id = Map.get(card, :id, Map.get(card, "id", 0)) min = Map.get(card, :min, Map.get(card, "min", 0)) {id, min} end) end defp parse_data(:pet, data) when is_list(data), do: data defp parse_data(:pet, data) when is_integer(data), do: [data] defp parse_data(:startscript, data), do: to_string(data) defp parse_data(:endscript, data), do: to_string(data) defp parse_data(:end, data), do: to_string(data) # Trait minimums defp parse_data(type, data) when type in [:charmMin, :senseMin, :craftMin, :willMin, :charismaMin, :insightMin] do if is_binary(data), do: String.to_integer(data), else: data end defp parse_data(_type, data), do: data # Requirement checking implementations defp do_check(:job, required_jobs, character) do # Check if character's job is in the list of acceptable jobs character_job = Map.get(character, :job, 0) character_job in required_jobs || Map.get(character, :gm, false) end defp do_check(:item, required_items, character) do # Check if character has required items # This is a simplified check - full implementation needs inventory lookup inventory = Map.get(character, :inventory, %{}) Enum.all?(required_items, fn {item_id, count} -> has_item_count(inventory, item_id, count) end) end defp do_check(:quest, required_quests, character) do # Check quest completion status quest_progress = Map.get(character, :quest_progress, %{}) Enum.all?(required_quests, fn {quest_id, required_state} -> actual_state = Map.get(quest_progress, quest_id, 0) # State: 0 = not started, 1 = in progress, 2 = completed actual_state == required_state end) end defp do_check(:lvmin, min_level, character) do Map.get(character, :level, 1) >= min_level end defp do_check(:lvmax, max_level, character) do Map.get(character, :level, 1) <= max_level end defp do_check(:mob, required_mobs, character) do # Check mob kill counts from quest progress mob_kills = Map.get(character, :quest_mob_kills, %{}) Enum.all?(required_mobs, fn {mob_id, count} -> Map.get(mob_kills, mob_id, 0) >= count end) end defp do_check(:npc, npc_id, character) do # NPC check is usually done at runtime with the actual NPC ID # This is a placeholder that returns true true end defp do_check(:npc, npc_id, character, talking_npc_id) do npc_id == talking_npc_id end defp do_check(:fieldEnter, maps, character) do current_map = Map.get(character, :map_id, 0) current_map in maps end defp do_check(:pop, min_fame, character) do Map.get(character, :fame, 0) >= min_fame end defp do_check(:interval, interval_minutes, character) do # Check if enough time has passed for repeatable quest last_completion = Map.get(character, :last_quest_completion, %{}) quest_id = Map.get(character, :checking_quest_id, 0) last_time = Map.get(last_completion, quest_id, 0) if last_time == 0 do true else current_time = System.system_time(:second) (current_time - last_time) >= interval_minutes * 60 end end defp do_check(:questComplete, min_completed, character) do completed_count = character |> Map.get(:quest_progress, %{}) |> Enum.count(fn {_id, state} -> state == 2 end) completed_count >= min_completed end defp do_check(:mbmin, min_cards, character) do # Monster book card count check monster_book = Map.get(character, :monster_book, %{}) card_count = map_size(monster_book) card_count >= min_cards end defp do_check(:skill, required_skills, character) do skills = Map.get(character, :skills, %{}) Enum.all?(required_skills, fn {skill_id, should_have} -> skill_level = Map.get(skills, skill_id, 0) master_level = Map.get(character, :skill_master_levels, %{}) |> Map.get(skill_id, 0) has_skill = skill_level > 0 || master_level > 0 if should_have do has_skill else not has_skill end end) end defp do_check(:pet, pet_ids, character) do pets = Map.get(character, :pets, []) Enum.any?(pet_ids, fn pet_id -> Enum.any?(pets, fn pet -> Map.get(pet, :item_id) == pet_id && Map.get(pet, :summoned, false) end) end) end defp do_check(:pettamenessmin, min_closeness, character) do pets = Map.get(character, :pets, []) Enum.any?(pets, fn pet -> Map.get(pet, :summoned, false) && Map.get(pet, :closeness, 0) >= min_closeness end) end defp do_check(:subJobFlags, flags, character) do subcategory = Map.get(character, :subcategory, 0) # Sub-job flags check (used for Dual Blade, etc.) subcategory == div(flags, 2) end defp do_check(:mbcard, required_cards, character) do monster_book = Map.get(character, :monster_book, %{}) Enum.all?(required_cards, fn {card_id, min_level} -> Map.get(monster_book, card_id, 0) >= min_level end) end defp do_check(:dayByDay, _data, _character) do # Daily quest - handled separately true end defp do_check(:normalAutoStart, _data, _character) do # Auto-start flag true end defp do_check(:partyQuest_S, _data, character) do # S-rank party quest check - simplified # Real implementation would check character's PQ history true end # Trait minimum checks defp do_check(trait_type, min_level, character) when trait_type in [:charmMin, :senseMin, :craftMin, :willMin, :charismaMin, :insightMin] do trait_name = case trait_type do :charmMin -> :charm :senseMin -> :sense :craftMin -> :craft :willMin -> :will :charismaMin -> :charisma :insightMin -> :insight end traits = Map.get(character, :traits, %{}) trait_level = Map.get(traits, trait_name, 0) trait_level >= min_level end defp do_check(:end, time_str, _character) do # Event end time check if time_str == nil || time_str == "" do true else # Parse YYYYMMDDHH format case String.length(time_str) do 10 -> year = String.slice(time_str, 0, 4) |> String.to_integer() month = String.slice(time_str, 4, 2) |> String.to_integer() day = String.slice(time_str, 6, 2) |> String.to_integer() hour = String.slice(time_str, 8, 2) |> String.to_integer() end_time = NaiveDateTime.new!(year, month, day, hour, 0, 0) now = NaiveDateTime.utc_now() NaiveDateTime.compare(now, end_time) == :lt _ -> true end end end defp do_check(:startscript, _script, _character), do: true defp do_check(:endscript, _script, _character), do: true defp do_check(:undefined, _data, _character), do: true defp do_check(_type, _data, _character), do: true # Helper functions defp has_item_count(inventory, item_id, required_count) when required_count > 0 do # Count items across all inventory types total = inventory |> Map.values() |> Enum.flat_map(& &1) |> Enum.filter(fn item -> Map.get(item, :item_id) == item_id end) |> Enum.map(fn item -> Map.get(item, :quantity, 1) end) |> Enum.sum() total >= required_count end defp has_item_count(inventory, item_id, required_count) when required_count <= 0 do # For negative counts (checking we DON'T have too many) total = inventory |> Map.values() |> Enum.flat_map(& &1) |> Enum.filter(fn item -> Map.get(item, :item_id) == item_id end) |> Enum.map(fn item -> Map.get(item, :quantity, 1) end) |> Enum.sum() # If required_count is 0 or negative, we should have 0 of the item # or specifically, not more than the absolute value total <= abs(required_count) end @doc "Checks if an item should show in drop for this quest" @spec shows_drop?(t(), integer(), Odinsea.Game.Character.t()) :: boolean() def shows_drop?(%__MODULE__{type: :item} = req, item_id, character) do # Check if this item is needed for the quest and should be shown in drops required_items = req.data case Map.get(required_items, item_id) do nil -> false required_count -> # Check if player still needs more of this item inventory = Map.get(character, :inventory, %{}) current_count = count_items(inventory, item_id) # Show drop if player needs more (required > current) # or if required_count is 0/negative (special case) current_count < required_count || required_count <= 0 end end def shows_drop?(_req, _item_id, _character), do: false defp count_items(inventory, item_id) do inventory |> Map.values() |> Enum.flat_map(& &1) |> Enum.filter(fn item -> Map.get(item, :item_id) == item_id end) |> Enum.map(fn item -> Map.get(item, :quantity, 1) end) |> Enum.sum() end end