kimi gone wild
This commit is contained in:
580
lib/odinsea/game/quest.ex
Normal file
580
lib/odinsea/game/quest.ex
Normal file
@@ -0,0 +1,580 @@
|
||||
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
|
||||
Reference in New Issue
Block a user