Files
odinsea-elixir/lib/odinsea/game/quest_action.ex
2026-02-14 23:12:33 -07:00

745 lines
21 KiB
Elixir

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