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

460 lines
13 KiB
Elixir

defmodule Odinsea.Game.QuestProgress do
@moduledoc """
Player Quest Progress tracking module.
Tracks individual player's quest states:
- Quest status (not started, in progress, completed)
- Mob kill counts for active quests
- Custom quest data (for scripted quests)
- Forfeiture count
- Completion time
- NPC ID (for quest tracking)
## Quest Status
- `0` - Not started
- `1` - In progress
- `2` - Completed
## Progress Structure
Each quest progress entry contains:
- Quest ID
- Status (0/1/2)
- Mob kills (map of mob_id => count)
- Forfeited count
- Completion time (timestamp)
- Custom data (string for scripted quests)
- NPC ID (related NPC for the quest)
"""
alias Odinsea.Game.Quest
defmodule ProgressEntry do
@moduledoc "Individual quest progress entry"
@type t :: %__MODULE__{
quest_id: integer(),
status: integer(),
mob_kills: %{integer() => integer()},
forfeited: integer(),
completion_time: integer() | nil,
custom_data: String.t() | nil,
npc_id: integer() | nil
}
defstruct [
:quest_id,
:status,
mob_kills: %{},
forfeited: 0,
completion_time: nil,
custom_data: nil,
npc_id: nil
]
end
@type t :: %__MODULE__{
character_id: integer(),
quests: %{integer() => ProgressEntry.t()}
}
defstruct [
:character_id,
quests: %{}
]
## Public API
@doc "Creates a new empty quest progress for a character"
@spec new(integer()) :: t()
def new(character_id) do
%__MODULE__{
character_id: character_id,
quests: %{}
}
end
@doc "Gets a quest's progress entry"
@spec get_quest(t(), integer()) :: ProgressEntry.t() | nil
def get_quest(%__MODULE__{} = progress, quest_id) do
Map.get(progress.quests, quest_id)
end
@doc "Gets the status of a quest"
@spec get_status(t(), integer()) :: integer()
def get_status(%__MODULE__{} = progress, quest_id) do
case get_quest(progress, quest_id) do
nil -> 0
entry -> entry.status
end
end
@doc "Checks if a quest is in progress"
@spec in_progress?(t(), integer()) :: boolean()
def in_progress?(%__MODULE__{} = progress, quest_id) do
get_status(progress, quest_id) == 1
end
@doc "Checks if a quest is completed"
@spec completed?(t(), integer()) :: boolean()
def completed?(%__MODULE__{} = progress, quest_id) do
get_status(progress, quest_id) == 2
end
@doc "Checks if a quest can be started"
@spec can_start?(t(), integer()) :: boolean()
def can_start?(%__MODULE__{} = progress, quest_id) do
status = get_status(progress, quest_id)
case Quest.get_quest(quest_id) do
nil ->
# Unknown quest, can't start
false
quest ->
# Can start if:
# 1. Status is 0 (not started), OR
# 2. Status is 2 (completed) AND quest is repeatable
status == 0 || (status == 2 && quest.repeatable)
end
end
@doc "Starts a quest"
@spec start_quest(t(), integer(), integer() | nil) :: t()
def start_quest(%__MODULE__{} = progress, quest_id, npc_id \\ nil) do
now = System.system_time(:second)
entry = %ProgressEntry{
quest_id: quest_id,
status: 1,
npc_id: npc_id,
mob_kills: %{},
completion_time: now
}
update_quest_entry(progress, entry)
end
@doc "Completes a quest"
@spec complete_quest(t(), integer(), integer() | nil) :: t()
def complete_quest(%__MODULE__{} = progress, quest_id, npc_id \\ nil) do
now = System.system_time(:second)
entry =
case get_quest(progress, quest_id) do
nil ->
%ProgressEntry{
quest_id: quest_id,
status: 2,
npc_id: npc_id,
completion_time: now
}
existing ->
%ProgressEntry{
existing
| status: 2,
npc_id: npc_id,
completion_time: now
}
end
update_quest_entry(progress, entry)
end
@doc "Forfeits a quest (abandons it)"
@spec forfeit_quest(t(), integer()) :: t()
def forfeit_quest(%__MODULE__{} = progress, quest_id) do
case get_quest(progress, quest_id) do
nil ->
# Quest not started, nothing to forfeit
progress
entry when entry.status == 1 ->
# Quest is in progress, forfeit it
forfeited = entry.forfeited + 1
updated_entry = %ProgressEntry{
quest_id: quest_id,
status: 0,
forfeited: forfeited,
completion_time: entry.completion_time,
custom_data: nil,
mob_kills: %{}
}
update_quest_entry(progress, updated_entry)
_entry ->
# Quest not in progress, can't forfeit
progress
end
end
@doc "Resets a quest to not started state"
@spec reset_quest(t(), integer()) :: t()
def reset_quest(%__MODULE__{} = progress, quest_id) do
%{progress | quests: Map.delete(progress.quests, quest_id)}
end
@doc "Records a mob kill for an active quest"
@spec record_mob_kill(t(), integer(), integer()) :: t()
def record_mob_kill(%__MODULE__{} = progress, quest_id, mob_id) do
case get_quest(progress, quest_id) do
nil ->
# Quest not started
progress
entry when entry.status != 1 ->
# Quest not in progress
progress
entry ->
# Check if this mob is relevant to the quest
case Quest.get_relevant_mobs(quest_id) do
%{^mob_id => required_count} ->
current_count = Map.get(entry.mob_kills, mob_id, 0)
# Only increment if not yet completed
new_count = min(current_count + 1, required_count)
updated_mob_kills = Map.put(entry.mob_kills, mob_id, new_count)
updated_entry = %ProgressEntry{entry | mob_kills: updated_mob_kills}
update_quest_entry(progress, updated_entry)
_ ->
# Mob not relevant to this quest
progress
end
end
end
@doc "Gets mob kill count for a quest"
@spec get_mob_kills(t(), integer(), integer()) :: integer()
def get_mob_kills(%__MODULE__{} = progress, quest_id, mob_id) do
case get_quest(progress, quest_id) do
nil -> 0
entry -> Map.get(entry.mob_kills, mob_id, 0)
end
end
@doc "Sets custom data for a quest"
@spec set_custom_data(t(), integer(), String.t()) :: t()
def set_custom_data(%__MODULE__{} = progress, quest_id, data) do
case get_quest(progress, quest_id) do
nil ->
# Quest not started, create entry with custom data
entry = %ProgressEntry{
quest_id: quest_id,
status: 1,
custom_data: data
}
update_quest_entry(progress, entry)
entry ->
updated_entry = %ProgressEntry{entry | custom_data: data}
update_quest_entry(progress, updated_entry)
end
end
@doc "Gets custom data for a quest"
@spec get_custom_data(t(), integer()) :: String.t() | nil
def get_custom_data(%__MODULE__{} = progress, quest_id) do
case get_quest(progress, quest_id) do
nil -> nil
entry -> entry.custom_data
end
end
@doc "Sets the NPC ID for a quest"
@spec set_npc(t(), integer(), integer()) :: t()
def set_npc(%__MODULE__{} = progress, quest_id, npc_id) do
case get_quest(progress, quest_id) do
nil ->
entry = %ProgressEntry{
quest_id: quest_id,
status: 0,
npc_id: npc_id
}
update_quest_entry(progress, entry)
entry ->
updated_entry = %ProgressEntry{entry | npc_id: npc_id}
update_quest_entry(progress, updated_entry)
end
end
@doc "Gets the NPC ID for a quest"
@spec get_npc(t(), integer()) :: integer() | nil
def get_npc(%__MODULE__{} = progress, quest_id) do
case get_quest(progress, quest_id) do
nil -> nil
entry -> entry.npc_id
end
end
@doc "Gets all active (in-progress) quests"
@spec get_active_quests(t()) :: [ProgressEntry.t()]
def get_active_quests(%__MODULE__{} = progress) do
progress.quests
|> Map.values()
|> Enum.filter(fn entry -> entry.status == 1 end)
end
@doc "Gets all completed quests"
@spec get_completed_quests(t()) :: [ProgressEntry.t()]
def get_completed_quests(%__MODULE__{} = progress) do
progress.quests
|> Map.values()
|> Enum.filter(fn entry -> entry.status == 2 end)
end
@doc "Gets count of completed quests"
@spec get_completed_count(t()) :: integer()
def get_completed_count(%__MODULE__{} = progress) do
progress.quests
|> Map.values()
|> Enum.count(fn entry -> entry.status == 2 end)
end
@doc "Checks if a quest can be repeated (interval passed)"
@spec can_repeat?(t(), integer()) :: boolean()
def can_repeat?(%__MODULE__{} = progress, quest_id) do
case Quest.get_quest(quest_id) do
nil -> false
quest ->
if not quest.repeatable do
false
else
case get_quest(progress, quest_id) do
nil -> true
entry ->
case entry.completion_time do
nil -> true
last_completion ->
# Check interval requirement
interval_req =
Enum.find(quest.complete_requirements, fn req ->
req.type == :interval
end)
interval_seconds =
case interval_req do
nil -> 0
req -> req.data * 60 # Convert minutes to seconds
end
now = System.system_time(:second)
(now - last_completion) >= interval_seconds
end
end
end
end
end
@doc "Converts progress to a map for database storage"
@spec to_map(t()) :: map()
def to_map(%__MODULE__{} = progress) do
%{
character_id: progress.character_id,
quests:
Enum.into(progress.quests, %{}, fn {quest_id, entry} ->
{quest_id, entry_to_map(entry)}
end)
}
end
@doc "Creates progress from a map (database deserialization)"
@spec from_map(map()) :: t()
def from_map(map) do
character_id = Map.get(map, :character_id, Map.get(map, "character_id", 0))
quests =
map
|> Map.get(:quests, Map.get(map, "quests", %{}))
|> Enum.into(%{}, fn {quest_id_str, entry_data} ->
quest_id =
if is_binary(quest_id_str) do
String.to_integer(quest_id_str)
else
quest_id_str
end
{quest_id, entry_from_map(entry_data)}
end)
%__MODULE__{
character_id: character_id,
quests: quests
}
end
@doc "Merges progress from database with current state"
@spec merge(t(), t()) :: t()
def merge(%__MODULE__{} = current, %__MODULE__{} = loaded) do
# Prefer loaded data for completed quests
# Keep current data for in-progress quests if newer
merged_quests =
Map.merge(loaded.quests, current.quests, fn _quest_id, loaded_entry, current_entry ->
cond do
loaded_entry.status == 2 and current_entry.status != 2 ->
# Keep completed status from loaded
loaded_entry
current_entry.status == 2 and loaded_entry.status != 2 ->
# Newly completed
current_entry
current_entry.completion_time && loaded_entry.completion_time ->
if current_entry.completion_time > loaded_entry.completion_time do
current_entry
else
loaded_entry
end
true ->
# Default to current
current_entry
end
end)
%__MODULE__{current | quests: merged_quests}
end
## Private Functions
defp update_quest_entry(%__MODULE__{} = progress, %ProgressEntry{} = entry) do
updated_quests = Map.put(progress.quests, entry.quest_id, entry)
%{progress | quests: updated_quests}
end
defp entry_to_map(%ProgressEntry{} = entry) do
%{
quest_id: entry.quest_id,
status: entry.status,
mob_kills: entry.mob_kills,
forfeited: entry.forfeited,
completion_time: entry.completion_time,
custom_data: entry.custom_data,
npc_id: entry.npc_id
}
end
defp entry_from_map(map) do
%ProgressEntry{
quest_id: Map.get(map, :quest_id, Map.get(map, "quest_id", 0)),
status: Map.get(map, :status, Map.get(map, "status", 0)),
mob_kills: Map.get(map, :mob_kills, Map.get(map, "mob_kills", %{})),
forfeited: Map.get(map, :forfeited, Map.get(map, "forfeited", 0)),
completion_time: Map.get(map, :completion_time, Map.get(map, "completion_time", nil)),
custom_data: Map.get(map, :custom_data, Map.get(map, "custom_data", nil)),
npc_id: Map.get(map, :npc_id, Map.get(map, "npc_id", nil))
}
end
end