defmodule Odinsea.Game.QuestAction do @moduledoc """ Quest Action module - defines rewards and effects for quest completion. Actions are executed when: - Starting a quest (start_actions) - Completing a quest (complete_actions) ## Action Types - `:exp` - Experience points reward - `:money` - Meso reward - `:item` - Item rewards (can be job/gender restricted) - `:pop` - Fame reward - `:sp` - Skill points reward - `:skill` - Learn specific skills - `:nextQuest` - Start another quest automatically - `:buffItemID` - Apply buff from item effect - `:infoNumber` - Info quest update - `:quest` - Update other quest states ## Trait EXP Types - `:charmEXP` - Charm trait experience - `:charismaEXP` - Charisma trait experience - `:craftEXP` - Craft (smithing) trait experience - `:insightEXP` - Insight trait experience - `:senseEXP` - Sense trait experience - `:willEXP` - Will trait experience ## Job Restrictions Items and skills can be restricted by job using job encoding: - Bit flags for job categories (Warrior, Magician, Bowman, Thief, Pirate, etc.) - Supports both 5-byte and simple encodings ## Gender Restrictions Items can be restricted by gender: - `0` - Male only - `1` - Female only - `2` - Both (no restriction) """ alias Odinsea.Game.Quest @type t :: %__MODULE__{ type: atom(), value: any(), applicable_jobs: [integer()], int_store: integer() } defstruct [:type, :value, :applicable_jobs, :int_store] defmodule QuestItem do @moduledoc "Quest item reward structure" @type t :: %__MODULE__{ item_id: integer(), count: integer(), period: integer(), gender: integer(), job: integer(), job_ex: integer(), prop: integer() } defstruct [ :item_id, :count, period: 0, gender: 2, job: -1, job_ex: -1, prop: -2 ] end ## Public API @doc "Creates a new quest action" @spec new(atom(), any(), keyword()) :: t() def new(type, value, opts \\ []) do %__MODULE__{ type: type, value: value, applicable_jobs: Keyword.get(opts, :applicable_jobs, []), int_store: Keyword.get(opts, :int_store, 0) } end @doc "Builds an action 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"))) {value, applicable_jobs, int_store} = parse_action_data(type, map) %__MODULE__{ type: type, value: value, applicable_jobs: applicable_jobs, int_store: int_store } end @doc "Parses a WZ action 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 "exp" -> :exp "item" -> :item "nextquest" -> :nextQuest "money" -> :money "quest" -> :quest "skill" -> :skill "pop" -> :pop "buffitemid" -> :buffItemID "infonumber" -> :infoNumber "sp" -> :sp "charismaexp" -> :charismaEXP "charmexp" -> :charmEXP "willexp" -> :willEXP "insightexp" -> :insightEXP "senseexp" -> :senseEXP "craftexp" -> :craftEXP "job" -> :job _ -> :undefined end end @doc "Runs start actions for a quest" @spec run_start(t(), Odinsea.Game.Character.t()) :: Odinsea.Game.Character.t() def run_start(%__MODULE__{} = action, character) do do_run_start(action.type, action, character) end @doc "Runs end/complete actions for a quest" @spec run_end(t(), Odinsea.Game.Character.t()) :: Odinsea.Game.Character.t() def run_end(%__MODULE__{} = action, character) do do_run_end(action.type, action, character) end @doc "Checks if character can receive this action's rewards (inventory space, etc.)" @spec check_end(t(), Odinsea.Game.Character.t()) :: boolean() def check_end(%__MODULE__{} = action, character) do do_check_end(action.type, action, character) end @doc "Checks if an item reward can be given to this character" @spec can_get_item?(QuestItem.t(), Odinsea.Game.Character.t()) :: boolean() def can_get_item?(%QuestItem{} = item, character) do # Check gender restriction gender_ok = if item.gender != 2 && item.gender >= 0 do character_gender = Map.get(character, :gender, 0) item.gender == character_gender else true end if not gender_ok do false else # Check job restriction if item.job > 0 do character_job = Map.get(character, :job, 0) job_codes = get_job_by_5byte_encoding(item.job) job_found = Enum.any?(job_codes, fn code -> div(code, 100) == div(character_job, 100) end) if not job_found and item.job_ex > 0 do job_codes_ex = get_job_by_simple_encoding(item.job_ex) job_found = Enum.any?(job_codes_ex, fn code -> rem(div(code, 100), 10) == rem(div(character_job, 100), 10) end) end job_found else true end end end @doc "Gets job list from 5-byte encoding" @spec get_job_by_5byte_encoding(integer()) :: [integer()] def get_job_by_5byte_encoding(encoded) do [] |> add_job_if(encoded, 0x1, 0) |> add_job_if(encoded, 0x2, 100) |> add_job_if(encoded, 0x4, 200) |> add_job_if(encoded, 0x8, 300) |> add_job_if(encoded, 0x10, 400) |> add_job_if(encoded, 0x20, 500) |> add_job_if(encoded, 0x400, 1000) |> add_job_if(encoded, 0x800, 1100) |> add_job_if(encoded, 0x1000, 1200) |> add_job_if(encoded, 0x2000, 1300) |> add_job_if(encoded, 0x4000, 1400) |> add_job_if(encoded, 0x8000, 1500) |> add_job_if(encoded, 0x20000, 2001) |> add_job_if(encoded, 0x20000, 2200) |> add_job_if(encoded, 0x100000, 2000) |> add_job_if(encoded, 0x100000, 2001) |> add_job_if(encoded, 0x200000, 2100) |> add_job_if(encoded, 0x400000, 2200) |> add_job_if(encoded, 0x40000000, 3000) |> add_job_if(encoded, 0x40000000, 3200) |> add_job_if(encoded, 0x40000000, 3300) |> add_job_if(encoded, 0x40000000, 3500) |> Enum.uniq() end @doc "Gets job list from simple encoding" @spec get_job_by_simple_encoding(integer()) :: [integer()] def get_job_by_simple_encoding(encoded) do [] |> add_job_if(encoded, 0x1, 200) |> add_job_if(encoded, 0x2, 300) |> add_job_if(encoded, 0x4, 400) |> add_job_if(encoded, 0x8, 500) end ## Private Functions defp add_job_if(list, encoded, flag, job) do if Bitwise.band(encoded, flag) != 0 do [job | list] else list end end defp parse_action_data(:exp, map) do int_store = Map.get(map, :value, Map.get(map, "value", Map.get(map, :int_store, 0))) {int_store, [], int_store} end defp parse_action_data(:money, map) do int_store = Map.get(map, :value, Map.get(map, "value", Map.get(map, :int_store, 0))) {int_store, [], int_store} end defp parse_action_data(:pop, map) do int_store = Map.get(map, :value, Map.get(map, "value", Map.get(map, :int_store, 0))) {int_store, [], int_store} end defp parse_action_data(:sp, map) do int_store = Map.get(map, :value, Map.get(map, "value", Map.get(map, :int_store, 0))) applicable_jobs = map |> Map.get(:applicable_jobs, Map.get(map, "applicable_jobs", [])) |> parse_job_list() {int_store, applicable_jobs, int_store} end defp parse_action_data(:item, map) do items = map |> Map.get(:value, Map.get(map, "value", [])) |> parse_item_list() {items, [], 0} end defp parse_action_data(:skill, map) do skills = map |> Map.get(:value, Map.get(map, "value", [])) |> parse_skill_list() applicable_jobs = map |> Map.get(:applicable_jobs, Map.get(map, "applicable_jobs", [])) |> parse_job_list() {skills, applicable_jobs, 0} end defp parse_action_data(:quest, map) do quests = map |> Map.get(:value, Map.get(map, "value", [])) |> parse_quest_state_list() {quests, [], 0} end defp parse_action_data(:nextQuest, map) do int_store = Map.get(map, :value, Map.get(map, "value", Map.get(map, :int_store, 0))) {int_store, [], int_store} end defp parse_action_data(:buffItemID, map) do int_store = Map.get(map, :value, Map.get(map, "value", Map.get(map, :int_store, 0))) {int_store, [], int_store} end defp parse_action_data(:infoNumber, map) do int_store = Map.get(map, :value, Map.get(map, "value", Map.get(map, :int_store, 0))) {int_store, [], int_store} end # Trait EXP actions defp parse_action_data(type, map) when type in [:charmEXP, :charismaEXP, :craftEXP, :insightEXP, :senseEXP, :willEXP] do int_store = Map.get(map, :value, Map.get(map, "value", Map.get(map, :int_store, 0))) {int_store, [], int_store} end defp parse_action_data(_type, map) do {Map.get(map, :value, nil), [], 0} end defp parse_job_list(nil), do: [] defp parse_job_list(list) when is_list(list), do: list defp parse_job_list(map) when is_map(map), do: Map.values(map) defp parse_item_list(items) when is_list(items) do Enum.map(items, fn item_data -> %QuestItem{ item_id: Map.get(item_data, :id, Map.get(item_data, "id", Map.get(item_data, :item_id, 0))), count: Map.get(item_data, :count, Map.get(item_data, "count", 1)), period: Map.get(item_data, :period, Map.get(item_data, "period", 0)), gender: Map.get(item_data, :gender, Map.get(item_data, "gender", 2)), job: Map.get(item_data, :job, Map.get(item_data, "job", -1)), job_ex: Map.get(item_data, :jobEx, Map.get(item_data, :job_ex, Map.get(item_data, "jobEx", -1))), prop: Map.get(item_data, :prop, Map.get(item_data, "prop", -2)) } end) end defp parse_item_list(_), do: [] defp parse_skill_list(skills) when is_list(skills) do Enum.map(skills, fn skill_data -> %{ skill_id: Map.get(skill_data, :id, Map.get(skill_data, "id", 0)), skill_level: Map.get(skill_data, :skill_level, Map.get(skill_data, "skillLevel", 0)), master_level: Map.get(skill_data, :master_level, Map.get(skill_data, "masterLevel", 0)) } end) end defp parse_skill_list(_), do: [] defp parse_quest_state_list(quests) when is_list(quests) do Enum.map(quests, fn quest_data -> { Map.get(quest_data, :id, Map.get(quest_data, "id", 0)), Map.get(quest_data, :state, Map.get(quest_data, "state", 0)) } end) end defp parse_quest_state_list(quests) when is_map(quests) do Enum.map(quests, fn {id, state} -> {String.to_integer(id), state} end) end defp parse_quest_state_list(_), do: [] # Start action implementations defp do_run_start(:exp, %{int_store: exp} = _action, character) do # Apply EXP with quest rate multiplier # Full implementation would check GameConstants.getExpRate_Quest and trait bonuses apply_exp(character, exp) end defp do_run_start(:money, %{int_store: meso} = _action, character) do current_meso = Map.get(character, :meso, 0) Map.put(character, :meso, current_meso + meso) end defp do_run_start(:pop, %{int_store: fame} = _action, character) do current_fame = Map.get(character, :fame, 0) Map.put(character, :fame, current_fame + fame) end defp do_run_start(:item, %{value: items} = action, character) do # Filter items by job/gender restrictions applicable_items = items |> Enum.filter(fn item -> can_get_item?(item, character) end) |> select_items_by_prop() # Add items to inventory (simplified - full implementation needs inventory manipulation) Enum.reduce(applicable_items, character, fn item, char -> add_item_to_character(char, item) end) end defp do_run_start(:sp, %{int_store: sp, applicable_jobs: jobs} = _action, character) do apply_skill_points(character, sp, jobs) end defp do_run_start(:skill, %{value: skills, applicable_jobs: jobs} = _action, character) do apply_skills(character, skills, jobs) end defp do_run_start(:quest, %{value: quest_states} = _action, character) do Enum.reduce(quest_states, character, fn {quest_id, state}, char -> update_quest_state(char, quest_id, state) end) end defp do_run_start(:nextQuest, %{int_store: next_quest_id} = _action, character) do # Queue next quest current_next = Map.get(character, :next_quest, nil) if current_next == nil do Map.put(character, :next_quest, next_quest_id) else character end end # Trait EXP start actions defp do_run_start(type, %{int_store: exp} = _action, character) when type in [:charmEXP, :charismaEXP, :craftEXP, :insightEXP, :senseEXP, :willEXP] do trait_name = case type do :charmEXP -> :charm :charismaEXP -> :charisma :craftEXP -> :craft :insightEXP -> :insight :senseEXP -> :sense :willEXP -> :will end apply_trait_exp(character, trait_name, exp) end defp do_run_start(_type, _action, character), do: character # End action implementations (mostly same as start but without forfeiture check) defp do_run_end(:exp, %{int_store: exp} = _action, character) do apply_exp(character, exp) end defp do_run_end(:money, %{int_store: meso} = _action, character) do current_meso = Map.get(character, :meso, 0) Map.put(character, :meso, current_meso + meso) end defp do_run_end(:pop, %{int_store: fame} = _action, character) do current_fame = Map.get(character, :fame, 0) Map.put(character, :fame, current_fame + fame) end defp do_run_end(:item, %{value: items} = action, character) do applicable_items = items |> Enum.filter(fn item -> can_get_item?(item, character) end) |> select_items_by_prop() Enum.reduce(applicable_items, character, fn item, char -> add_item_to_character(char, item) end) end defp do_run_end(:sp, %{int_store: sp, applicable_jobs: jobs} = _action, character) do apply_skill_points(character, sp, jobs) end defp do_run_end(:skill, %{value: skills, applicable_jobs: jobs} = _action, character) do apply_skills(character, skills, jobs) end defp do_run_end(:quest, %{value: quest_states} = _action, character) do Enum.reduce(quest_states, character, fn {quest_id, state}, char -> update_quest_state(char, quest_id, state) end) end defp do_run_end(:nextQuest, %{int_store: next_quest_id} = _action, character) do current_next = Map.get(character, :next_quest, nil) if current_next == nil do Map.put(character, :next_quest, next_quest_id) else character end end defp do_run_end(:buffItemID, %{int_store: item_id} = _action, character) when item_id > 0 do # Apply item buff effect # Full implementation would get item effect from ItemInformationProvider character end # Trait EXP end actions defp do_run_end(type, %{int_store: exp} = _action, character) when type in [:charmEXP, :charismaEXP, :craftEXP, :insightEXP, :senseEXP, :willEXP] do trait_name = case type do :charmEXP -> :charm :charismaEXP -> :charisma :craftEXP -> :craft :insightEXP -> :insight :senseEXP -> :sense :willEXP -> :will end apply_trait_exp(character, trait_name, exp) end defp do_run_end(_type, _action, character), do: character # Check end implementations defp do_check_end(:item, %{value: items} = action, character) do # Check if character has inventory space for items applicable_items = items |> Enum.filter(fn item -> can_get_item?(item, character) end) |> select_items_by_prop() # Count items by inventory type needed_slots = Enum.reduce(applicable_items, %{equip: 0, use: 0, setup: 0, etc: 0, cash: 0}, fn item, acc -> inv_type = get_inventory_type(item.item_id) Map.update(acc, inv_type, 1, &(&1 + 1)) end) # Check available space (simplified) inventory = Map.get(character, :inventory, %{}) Enum.all?(needed_slots, fn {type, count} -> current_items = Map.get(inventory, type, []) max_slots = get_max_slots(type) length(current_items) + count <= max_slots end) end defp do_check_end(:money, %{int_store: meso} = _action, character) do current_meso = Map.get(character, :meso, 0) cond do meso > 0 and current_meso + meso > 2_147_483_647 -> # Would overflow false meso < 0 and current_meso < abs(meso) -> # Not enough meso false true -> true end end defp do_check_end(_type, _action, _character), do: true # Helper functions defp select_items_by_prop(items) do # Handle probability-based item selection # Items with prop > 0 are selected randomly # Items with prop == -1 are selection-based (user chooses) # Items with prop == -2 are always given {random_items, other_items} = Enum.split_with(items, fn item -> item.prop > 0 end) if length(random_items) > 0 do # Create weighted pool pool = Enum.flat_map(random_items, fn item -> List.duplicate(item, item.prop) end) selected = Enum.random(pool) [selected | other_items] else other_items end end defp add_item_to_character(character, %QuestItem{} = item) do inventory = Map.get(character, :inventory, %{}) inv_type = get_inventory_type(item.item_id) new_item = %{ item_id: item.item_id, quantity: item.count, position: find_next_slot(inventory, inv_type), expiration: if(item.period > 0, do: System.system_time(:second) + item.period * 60, else: -1) } updated_inventory = Map.update(inventory, inv_type, [new_item], fn items -> [new_item | items] end) Map.put(character, :inventory, updated_inventory) end defp get_inventory_type(item_id) do prefix = div(item_id, 1_000_000) case prefix do 1 -> :equip 2 -> :use 3 -> :setup 4 -> :etc 5 -> :cash _ -> :etc end end defp get_max_slots(type) do case type do :equip -> 24 :use -> 80 :setup -> 80 :etc -> 80 :cash -> 40 _ -> 80 end end defp find_next_slot(inventory, type) do items = Map.get(inventory, type, []) positions = Enum.map(items, & &1.position) Enum.find(1..100, fn slot -> slot not in positions end) || 0 end defp apply_exp(character, base_exp) do level = Map.get(character, :level, 1) # Apply quest EXP rate exp_rate = 1.0 # Would get from GameConstants # Apply trait bonus (Sense trait gives quest EXP bonus) traits = Map.get(character, :traits, %{}) sense_level = Map.get(traits, :sense, 0) trait_bonus = 1.0 + (sense_level * 3 / 1000) final_exp = trunc(base_exp * exp_rate * trait_bonus) # Add EXP to character current_exp = Map.get(character, :exp, 0) Map.put(character, :exp, current_exp + final_exp) end defp apply_skill_points(character, sp, jobs) do character_job = Map.get(character, :job, 0) # Find most applicable job applicable_job = jobs |> Enum.filter(fn job -> character_job >= job end) |> Enum.max(fn -> 0 end) sp_type = if applicable_job == 0 do # Beginner SP 0 else # Get skill book based on job get_skill_book(applicable_job) end current_sp = Map.get(character, :sp, []) updated_sp = List.replace_at(current_sp, sp_type, (Enum.at(current_sp, sp_type, 0) || 0) + sp) Map.put(character, :sp, updated_sp) end defp get_skill_book(job) do # Get skill book index for job cond do job >= 1000 and job < 2000 -> 1 job >= 2000 and job < 3000 -> 2 job >= 3000 and job < 4000 -> 3 job >= 4000 and job < 5000 -> 4 true -> 0 end end defp apply_skills(character, skills, applicable_jobs) do character_job = Map.get(character, :job, 0) # Check if any job matches job_matches = Enum.any?(applicable_jobs, fn job -> character_job == job end) if job_matches or applicable_jobs == [] do current_skills = Map.get(character, :skills, %{}) current_master_levels = Map.get(character, :skill_master_levels, %{}) Enum.reduce(skills, character, fn skill, char -> skill_id = skill.skill_id skill_level = skill.skill_level master_level = skill.master_level # Get current levels current_level = Map.get(current_skills, skill_id, 0) current_master = Map.get(current_master_levels, skill_id, 0) # Update with max of current/new new_skills = Map.put(current_skills, skill_id, max(skill_level, current_level)) new_masters = Map.put(current_master_levels, skill_id, max(master_level, current_master)) char |> Map.put(:skills, new_skills) |> Map.put(:skill_master_levels, new_masters) end) else character end end defp update_quest_state(character, quest_id, state) do quest_progress = Map.get(character, :quest_progress, %{}) updated_progress = Map.put(quest_progress, quest_id, state) Map.put(character, :quest_progress, updated_progress) end defp apply_trait_exp(character, trait_name, exp) do traits = Map.get(character, :traits, %{}) current_exp = Map.get(traits, trait_name, 0) updated_traits = Map.put(traits, trait_name, current_exp + exp) Map.put(character, :traits, updated_traits) end end