479 lines
15 KiB
Elixir
479 lines
15 KiB
Elixir
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
|