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

581 lines
16 KiB
Elixir

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