kimi gone wild
This commit is contained in:
335
lib/odinsea/game/attack_info.ex
Normal file
335
lib/odinsea/game/attack_info.ex
Normal file
@@ -0,0 +1,335 @@
|
||||
defmodule Odinsea.Game.AttackInfo do
|
||||
use Bitwise
|
||||
@moduledoc """
|
||||
Attack information struct and parser functions.
|
||||
Ported from src/handling/channel/handler/AttackInfo.java and DamageParse.java
|
||||
"""
|
||||
|
||||
alias Odinsea.Net.Packet.In
|
||||
require Logger
|
||||
|
||||
@type attack_type :: :melee | :ranged | :magic | :melee_with_mirror | :ranged_with_shadowpartner
|
||||
|
||||
@type damage_entry :: %{
|
||||
mob_oid: integer(),
|
||||
damages: list({integer(), boolean()})
|
||||
}
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
skill: integer(),
|
||||
charge: integer(),
|
||||
last_attack_tick: integer(),
|
||||
all_damage: list(damage_entry()),
|
||||
position: %{x: integer(), y: integer()},
|
||||
display: integer(),
|
||||
hits: integer(),
|
||||
targets: integer(),
|
||||
tbyte: integer(),
|
||||
speed: integer(),
|
||||
csstar: integer(),
|
||||
aoe: integer(),
|
||||
slot: integer(),
|
||||
unk: integer(),
|
||||
delay: integer(),
|
||||
real: boolean(),
|
||||
attack_type: attack_type()
|
||||
}
|
||||
|
||||
defstruct [
|
||||
:skill,
|
||||
:charge,
|
||||
:last_attack_tick,
|
||||
:all_damage,
|
||||
:position,
|
||||
:display,
|
||||
:hits,
|
||||
:targets,
|
||||
:tbyte,
|
||||
:speed,
|
||||
:csstar,
|
||||
:aoe,
|
||||
:slot,
|
||||
:unk,
|
||||
:delay,
|
||||
:real,
|
||||
:attack_type
|
||||
]
|
||||
|
||||
@doc """
|
||||
Parse melee/close-range attack packet (CP_CLOSE_RANGE_ATTACK).
|
||||
Ported from DamageParse.parseDmgM()
|
||||
"""
|
||||
def parse_melee_attack(packet, opts \\ []) do
|
||||
energy = Keyword.get(opts, :energy, false)
|
||||
|
||||
# Decode attack header
|
||||
{tbyte, packet} = In.decode_byte(packet)
|
||||
targets = (tbyte >>> 4) &&& 0xF
|
||||
hits = tbyte &&& 0xF
|
||||
|
||||
{skill, packet} = In.decode_int(packet)
|
||||
|
||||
# Skip GMS-specific fields (9 bytes in GMS)
|
||||
{_, packet} = In.skip(packet, 9)
|
||||
|
||||
# Handle charge skills
|
||||
{charge, packet} =
|
||||
case skill do
|
||||
5_101_004 -> In.decode_int(packet) # Corkscrew
|
||||
15_101_003 -> In.decode_int(packet) # Cygnus corkscrew
|
||||
5_201_002 -> In.decode_int(packet) # Gernard
|
||||
14_111_006 -> In.decode_int(packet) # Poison bomb
|
||||
4_341_002 -> In.decode_int(packet)
|
||||
4_341_003 -> In.decode_int(packet)
|
||||
5_301_001 -> In.decode_int(packet)
|
||||
5_300_007 -> In.decode_int(packet)
|
||||
_ -> {0, packet}
|
||||
end
|
||||
|
||||
{unk, packet} = In.decode_byte(packet)
|
||||
{display, packet} = In.decode_ushort(packet)
|
||||
|
||||
# Skip 4 bytes (big bang) + 1 byte (weapon class)
|
||||
{_, packet} = In.skip(packet, 5)
|
||||
|
||||
{speed, packet} = In.decode_byte(packet)
|
||||
{last_attack_tick, packet} = In.decode_int(packet)
|
||||
|
||||
# Skip 4 bytes (padding)
|
||||
{_, packet} = In.skip(packet, 4)
|
||||
|
||||
# Meso Explosion special handling
|
||||
if skill == 4_211_006 do
|
||||
parse_meso_explosion(packet, %__MODULE__{
|
||||
skill: skill,
|
||||
charge: charge,
|
||||
last_attack_tick: last_attack_tick,
|
||||
display: display,
|
||||
hits: hits,
|
||||
targets: targets,
|
||||
tbyte: tbyte,
|
||||
speed: speed,
|
||||
unk: unk,
|
||||
real: true,
|
||||
attack_type: :melee
|
||||
})
|
||||
else
|
||||
# Parse damage for each target
|
||||
{all_damage, packet} = parse_damage_targets(packet, targets, hits, [])
|
||||
|
||||
{position, _packet} = In.decode_point(packet)
|
||||
|
||||
{:ok,
|
||||
%__MODULE__{
|
||||
skill: skill,
|
||||
charge: charge,
|
||||
last_attack_tick: last_attack_tick,
|
||||
all_damage: all_damage,
|
||||
position: position,
|
||||
display: display,
|
||||
hits: hits,
|
||||
targets: targets,
|
||||
tbyte: tbyte,
|
||||
speed: speed,
|
||||
unk: unk,
|
||||
real: true,
|
||||
attack_type: :melee
|
||||
}}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Parse ranged attack packet (CP_RANGED_ATTACK).
|
||||
Ported from DamageParse.parseDmgR()
|
||||
"""
|
||||
def parse_ranged_attack(packet) do
|
||||
# Decode attack header
|
||||
{tbyte, packet} = In.decode_byte(packet)
|
||||
targets = (tbyte >>> 4) &&& 0xF
|
||||
hits = tbyte &&& 0xF
|
||||
|
||||
{skill, packet} = In.decode_int(packet)
|
||||
|
||||
# Skip GMS-specific fields (10 bytes in GMS)
|
||||
{_, packet} = In.skip(packet, 10)
|
||||
|
||||
# Handle special skills with extra 4 bytes
|
||||
{_, packet} =
|
||||
case skill do
|
||||
3_121_004 -> In.skip(packet, 4) # Hurricane
|
||||
3_221_001 -> In.skip(packet, 4) # Pierce
|
||||
5_221_004 -> In.skip(packet, 4) # Rapidfire
|
||||
13_111_002 -> In.skip(packet, 4) # Cygnus Hurricane
|
||||
33_121_009 -> In.skip(packet, 4)
|
||||
35_001_001 -> In.skip(packet, 4)
|
||||
35_101_009 -> In.skip(packet, 4)
|
||||
23_121_000 -> In.skip(packet, 4)
|
||||
5_311_002 -> In.skip(packet, 4)
|
||||
_ -> {nil, packet}
|
||||
end
|
||||
|
||||
{unk, packet} = In.decode_byte(packet)
|
||||
{display, packet} = In.decode_ushort(packet)
|
||||
|
||||
# Skip 4 bytes (big bang) + 1 byte (weapon class)
|
||||
{_, packet} = In.skip(packet, 5)
|
||||
|
||||
{speed, packet} = In.decode_byte(packet)
|
||||
{last_attack_tick, packet} = In.decode_int(packet)
|
||||
|
||||
# Skip 4 bytes (padding)
|
||||
{_, packet} = In.skip(packet, 4)
|
||||
|
||||
{slot, packet} = In.decode_short(packet)
|
||||
{csstar, packet} = In.decode_short(packet)
|
||||
{aoe, packet} = In.decode_byte(packet)
|
||||
|
||||
# Parse damage for each target
|
||||
{all_damage, packet} = parse_damage_targets(packet, targets, hits, [])
|
||||
|
||||
# Skip 4 bytes before position
|
||||
{_, packet} = In.skip(packet, 4)
|
||||
|
||||
{position, _packet} = In.decode_point(packet)
|
||||
|
||||
{:ok,
|
||||
%__MODULE__{
|
||||
skill: skill,
|
||||
charge: -1,
|
||||
last_attack_tick: last_attack_tick,
|
||||
all_damage: all_damage,
|
||||
position: position,
|
||||
display: display,
|
||||
hits: hits,
|
||||
targets: targets,
|
||||
tbyte: tbyte,
|
||||
speed: speed,
|
||||
csstar: csstar,
|
||||
aoe: aoe,
|
||||
slot: slot,
|
||||
unk: unk,
|
||||
real: true,
|
||||
attack_type: :ranged
|
||||
}}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Parse magic attack packet (CP_MAGIC_ATTACK).
|
||||
Ported from DamageParse.parseDmgMa()
|
||||
"""
|
||||
def parse_magic_attack(packet) do
|
||||
# Decode attack header
|
||||
{tbyte, packet} = In.decode_byte(packet)
|
||||
targets = (tbyte >>> 4) &&& 0xF
|
||||
hits = tbyte &&& 0xF
|
||||
|
||||
{skill, packet} = In.decode_int(packet)
|
||||
|
||||
# Return error if invalid skill
|
||||
if skill >= 91_000_000 do
|
||||
{:error, :invalid_skill}
|
||||
else
|
||||
# Skip GMS-specific fields (9 bytes in GMS)
|
||||
{_, packet} = In.skip(packet, 9)
|
||||
|
||||
# Handle charge skills
|
||||
{charge, packet} =
|
||||
if is_magic_charge_skill?(skill) do
|
||||
In.decode_int(packet)
|
||||
else
|
||||
{-1, packet}
|
||||
end
|
||||
|
||||
{unk, packet} = In.decode_byte(packet)
|
||||
{display, packet} = In.decode_ushort(packet)
|
||||
|
||||
# Skip 4 bytes (big bang) + 1 byte (weapon class)
|
||||
{_, packet} = In.skip(packet, 5)
|
||||
|
||||
{speed, packet} = In.decode_byte(packet)
|
||||
{last_attack_tick, packet} = In.decode_int(packet)
|
||||
|
||||
# Skip 4 bytes (padding)
|
||||
{_, packet} = In.skip(packet, 4)
|
||||
|
||||
# Parse damage for each target
|
||||
{all_damage, packet} = parse_damage_targets(packet, targets, hits, [])
|
||||
|
||||
{position, _packet} = In.decode_point(packet)
|
||||
|
||||
{:ok,
|
||||
%__MODULE__{
|
||||
skill: skill,
|
||||
charge: charge,
|
||||
last_attack_tick: last_attack_tick,
|
||||
all_damage: all_damage,
|
||||
position: position,
|
||||
display: display,
|
||||
hits: hits,
|
||||
targets: targets,
|
||||
tbyte: tbyte,
|
||||
speed: speed,
|
||||
unk: unk,
|
||||
real: true,
|
||||
attack_type: :magic
|
||||
}}
|
||||
end
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Private Helper Functions
|
||||
# ============================================================================
|
||||
|
||||
defp parse_damage_targets(_packet, 0, _hits, acc), do: {Enum.reverse(acc), <<>>}
|
||||
|
||||
defp parse_damage_targets(packet, targets_remaining, hits, acc) do
|
||||
{mob_oid, packet} = In.decode_int(packet)
|
||||
|
||||
# Skip 12 bytes: [1] Always 6?, [3] unk, [4] Pos1, [4] Pos2
|
||||
{_, packet} = In.skip(packet, 12)
|
||||
|
||||
{delay, packet} = In.decode_short(packet)
|
||||
|
||||
# Parse damage values for this target
|
||||
{damages, packet} = parse_damage_hits(packet, hits, [])
|
||||
|
||||
# Skip 4 bytes: CRC of monster [Wz Editing]
|
||||
{_, packet} = In.skip(packet, 4)
|
||||
|
||||
damage_entry = %{
|
||||
mob_oid: mob_oid,
|
||||
damages: damages,
|
||||
delay: delay
|
||||
}
|
||||
|
||||
parse_damage_targets(packet, targets_remaining - 1, hits, [damage_entry | acc])
|
||||
end
|
||||
|
||||
defp parse_damage_hits(_packet, 0, acc), do: {Enum.reverse(acc), <<>>}
|
||||
|
||||
defp parse_damage_hits(packet, hits_remaining, acc) do
|
||||
{damage, packet} = In.decode_int(packet)
|
||||
# Second boolean is for critical hit (not used in v342)
|
||||
parse_damage_hits(packet, hits_remaining - 1, [{damage, false} | acc])
|
||||
end
|
||||
|
||||
defp parse_meso_explosion(packet, attack_info) do
|
||||
# TODO: Implement meso explosion parsing
|
||||
# For now, return empty damage list
|
||||
Logger.warning("Meso explosion not yet implemented")
|
||||
|
||||
{:ok,
|
||||
%{attack_info | all_damage: [], position: %{x: 0, y: 0}}}
|
||||
end
|
||||
|
||||
defp is_magic_charge_skill?(skill_id) do
|
||||
skill_id in [
|
||||
# Big Bang skills
|
||||
2_121_001,
|
||||
2_221_001,
|
||||
2_321_001,
|
||||
# Elemental Charge skills
|
||||
12_111_004
|
||||
]
|
||||
end
|
||||
end
|
||||
@@ -11,7 +11,7 @@ defmodule Odinsea.Game.Character do
|
||||
|
||||
alias Odinsea.Database.Schema.Character, as: CharacterDB
|
||||
alias Odinsea.Game.Map, as: GameMap
|
||||
alias Odinsea.Game.{Inventory, InventoryType}
|
||||
alias Odinsea.Game.{Inventory, InventoryType, Pet}
|
||||
alias Odinsea.Net.Packet.Out
|
||||
|
||||
# ============================================================================
|
||||
@@ -92,6 +92,8 @@ defmodule Odinsea.Game.Character do
|
||||
:skin_color,
|
||||
:hair,
|
||||
:face,
|
||||
# GM Level (0 = normal player, >0 = GM)
|
||||
:gm,
|
||||
# Stats
|
||||
:stats,
|
||||
# Position & Map
|
||||
@@ -131,6 +133,7 @@ defmodule Odinsea.Game.Character do
|
||||
skin_color: byte(),
|
||||
hair: non_neg_integer(),
|
||||
face: non_neg_integer(),
|
||||
gm: non_neg_integer(),
|
||||
stats: Stats.t(),
|
||||
map_id: non_neg_integer(),
|
||||
position: Position.t(),
|
||||
@@ -513,6 +516,7 @@ defmodule Odinsea.Game.Character do
|
||||
skin_color: db_char.skin_color,
|
||||
hair: db_char.hair,
|
||||
face: db_char.face,
|
||||
gm: db_char.gm,
|
||||
stats: stats,
|
||||
map_id: db_char.map_id,
|
||||
position: position,
|
||||
@@ -592,10 +596,258 @@ defmodule Odinsea.Game.Character do
|
||||
|
||||
# Save character base data
|
||||
result = Odinsea.Database.Context.update_character(state.character_id, attrs)
|
||||
|
||||
|
||||
# Save inventories
|
||||
Odinsea.Database.Context.save_character_inventory(state.character_id, state.inventories)
|
||||
|
||||
|
||||
result
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gives EXP to the character.
|
||||
Handles level-up, EXP calculation, and packet broadcasting.
|
||||
"""
|
||||
def gain_exp(character_pid, exp_amount, is_highest_damage \\ false) when is_pid(character_pid) do
|
||||
GenServer.cast(character_pid, {:gain_exp, exp_amount, is_highest_damage})
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Pet API
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Gets a pet by slot index (0-2 for the 3 pet slots).
|
||||
"""
|
||||
def get_pet(character_id, slot) do
|
||||
GenServer.call(via_tuple(character_id), {:get_pet, slot})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets all summoned pets.
|
||||
"""
|
||||
def get_summoned_pets(character_id) do
|
||||
GenServer.call(via_tuple(character_id), :get_summoned_pets)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Spawns a pet from inventory to the map.
|
||||
"""
|
||||
def spawn_pet(character_id, inventory_slot, lead \\ false) do
|
||||
GenServer.call(via_tuple(character_id), {:spawn_pet, inventory_slot, lead})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Despawns a pet from the map.
|
||||
"""
|
||||
def despawn_pet(character_id, slot) do
|
||||
GenServer.call(via_tuple(character_id), {:despawn_pet, slot})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Updates a pet's data (after feeding, command, etc.).
|
||||
"""
|
||||
def update_pet(character_id, pet) do
|
||||
GenServer.cast(via_tuple(character_id), {:update_pet, pet})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Updates a pet's position.
|
||||
"""
|
||||
def update_pet_position(character_id, slot, position) do
|
||||
GenServer.cast(via_tuple(character_id), {:update_pet_position, slot, position})
|
||||
end
|
||||
|
||||
def gain_exp(character_id, exp_amount, is_highest_damage) when is_integer(character_id) do
|
||||
case Registry.lookup(Odinsea.CharacterRegistry, character_id) do
|
||||
[{pid, _}] -> gain_exp(pid, exp_amount, is_highest_damage)
|
||||
[] -> {:error, :character_not_found}
|
||||
end
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# GenServer Callbacks - EXP Gain
|
||||
# ============================================================================
|
||||
|
||||
@impl true
|
||||
def handle_cast({:gain_exp, exp_amount, is_highest_damage}, state) do
|
||||
# Calculate EXP needed for next level
|
||||
exp_needed = calculate_exp_needed(state.level)
|
||||
|
||||
# Add EXP
|
||||
new_exp = state.exp + exp_amount
|
||||
|
||||
# Check for level up
|
||||
{new_state, leveled_up} =
|
||||
if new_exp >= exp_needed and state.level < 200 do
|
||||
# Level up!
|
||||
new_level = state.level + 1
|
||||
|
||||
# Calculate stat gains (simple formula for now)
|
||||
# TODO: Use job-specific stat gain formulas
|
||||
hp_gain = 10 + div(state.stats.str, 10)
|
||||
mp_gain = 5 + div(state.stats.int, 10)
|
||||
|
||||
new_stats = %{
|
||||
state.stats
|
||||
| max_hp: state.stats.max_hp + hp_gain,
|
||||
max_mp: state.stats.max_mp + mp_gain,
|
||||
hp: state.stats.max_hp + hp_gain,
|
||||
mp: state.stats.max_mp + mp_gain
|
||||
}
|
||||
|
||||
updated_state = %{
|
||||
state
|
||||
| level: new_level,
|
||||
exp: new_exp - exp_needed,
|
||||
stats: new_stats,
|
||||
remaining_ap: state.remaining_ap + 5
|
||||
}
|
||||
|
||||
Logger.info("Character #{state.name} leveled up to #{new_level}!")
|
||||
|
||||
# TODO: Send level-up packet to client
|
||||
# TODO: Broadcast level-up effect to map
|
||||
|
||||
{updated_state, true}
|
||||
else
|
||||
{%{state | exp: new_exp}, false}
|
||||
end
|
||||
|
||||
# TODO: Send EXP gain packet to client
|
||||
# TODO: If highest damage, send bonus message
|
||||
|
||||
Logger.debug(
|
||||
"Character #{state.name} gained #{exp_amount} EXP (total: #{new_state.exp}, level: #{new_state.level})"
|
||||
)
|
||||
|
||||
{:noreply, new_state}
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# GenServer Callbacks - Pet Functions
|
||||
# ============================================================================
|
||||
|
||||
@impl true
|
||||
def handle_call({:get_pet, slot}, _from, state) do
|
||||
# Find pet by slot (1, 2, or 3)
|
||||
pet = Enum.find(state.pets, fn p -> p.summoned == slot end)
|
||||
|
||||
if pet do
|
||||
{:reply, {:ok, pet}, state}
|
||||
else
|
||||
{:reply, {:error, :pet_not_found}, state}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call(:get_summoned_pets, _from, state) do
|
||||
# Return list of {slot, pet} tuples for all summoned pets
|
||||
summoned = state.pets
|
||||
|> Enum.filter(fn p -> p.summoned > 0 end)
|
||||
|> Enum.map(fn p -> {p.summoned, p} end)
|
||||
|
||||
{:reply, summoned, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:spawn_pet, inventory_slot, lead}, _from, state) do
|
||||
# Get pet from cash inventory
|
||||
cash_inv = Map.get(state.inventories, :cash, Inventory.new(:cash))
|
||||
item = Inventory.get_item(cash_inv, inventory_slot)
|
||||
|
||||
cond do
|
||||
is_nil(item) ->
|
||||
{:reply, {:error, :item_not_found}, state}
|
||||
|
||||
is_nil(item.pet) ->
|
||||
{:reply, {:error, :not_a_pet}, state}
|
||||
|
||||
true ->
|
||||
# Find available slot (1, 2, or 3)
|
||||
used_slots = state.pets |> Enum.map(& &1.summoned) |> Enum.filter(& &1 > 0)
|
||||
available_slots = [1, 2, 3] -- used_slots
|
||||
|
||||
if available_slots == [] do
|
||||
{:reply, {:error, :no_slots_available}, state}
|
||||
else
|
||||
slot = if lead, do: 1, else: List.first(available_slots)
|
||||
|
||||
# Update pet with summoned slot and position
|
||||
pet = item.pet
|
||||
|> Pet.set_summoned(slot)
|
||||
|> Pet.set_inventory_position(inventory_slot)
|
||||
|> Pet.update_position(state.position.x, state.position.y)
|
||||
|
||||
# Add or update pet in state
|
||||
existing_index = Enum.find_index(state.pets, fn p -> p.unique_id == pet.unique_id end)
|
||||
|
||||
new_pets = if existing_index do
|
||||
List.replace_at(state.pets, existing_index, pet)
|
||||
else
|
||||
[pet | state.pets]
|
||||
end
|
||||
|
||||
new_state = %{state | pets: new_pets, updated_at: DateTime.utc_now()}
|
||||
{:reply, {:ok, pet}, new_state}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:despawn_pet, slot}, _from, state) do
|
||||
case Enum.find(state.pets, fn p -> p.summoned == slot end) do
|
||||
nil ->
|
||||
{:reply, {:error, :pet_not_found}, state}
|
||||
|
||||
pet ->
|
||||
# Set summoned to 0
|
||||
updated_pet = Pet.set_summoned(pet, 0)
|
||||
|
||||
# Update in state
|
||||
pet_index = Enum.find_index(state.pets, fn p -> p.unique_id == pet.unique_id end)
|
||||
new_pets = List.replace_at(state.pets, pet_index, updated_pet)
|
||||
|
||||
new_state = %{state | pets: new_pets, updated_at: DateTime.utc_now()}
|
||||
{:reply, {:ok, updated_pet}, new_state}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_cast({:update_pet, pet}, state) do
|
||||
# Find and update pet
|
||||
pet_index = Enum.find_index(state.pets, fn p -> p.unique_id == pet.unique_id end)
|
||||
|
||||
new_pets = if pet_index do
|
||||
List.replace_at(state.pets, pet_index, pet)
|
||||
else
|
||||
[pet | state.pets]
|
||||
end
|
||||
|
||||
{:noreply, %{state | pets: new_pets, updated_at: DateTime.utc_now()}}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_cast({:update_pet_position, slot, position}, state) do
|
||||
# Find pet by slot and update position
|
||||
case Enum.find_index(state.pets, fn p -> p.summoned == slot end) do
|
||||
nil ->
|
||||
{:noreply, state}
|
||||
|
||||
index ->
|
||||
pet = Enum.at(state.pets, index)
|
||||
updated_pet = Pet.update_position(pet, position.x, position.y, position.fh, position.stance)
|
||||
new_pets = List.replace_at(state.pets, index, updated_pet)
|
||||
|
||||
{:noreply, %{state | pets: new_pets}}
|
||||
end
|
||||
end
|
||||
|
||||
# Calculate EXP needed to reach next level
|
||||
defp calculate_exp_needed(level) when level >= 200, do: 0
|
||||
|
||||
defp calculate_exp_needed(level) do
|
||||
# Simple formula: level^3 + 100 * level
|
||||
# TODO: Use actual MapleStory EXP table
|
||||
level * level * level + 100 * level
|
||||
end
|
||||
end
|
||||
|
||||
238
lib/odinsea/game/damage_calc.ex
Normal file
238
lib/odinsea/game/damage_calc.ex
Normal file
@@ -0,0 +1,238 @@
|
||||
defmodule Odinsea.Game.DamageCalc do
|
||||
use Bitwise
|
||||
@moduledoc """
|
||||
Damage calculation and application module.
|
||||
Ported from src/handling/channel/handler/DamageParse.java
|
||||
"""
|
||||
|
||||
require Logger
|
||||
|
||||
alias Odinsea.Game.{AttackInfo, Character, Map, Monster}
|
||||
alias Odinsea.Net.Packet.Out
|
||||
alias Odinsea.Net.Opcodes
|
||||
alias Odinsea.Channel.Packets
|
||||
|
||||
@doc """
|
||||
Apply attack to monsters on the map.
|
||||
Ported from DamageParse.applyAttack()
|
||||
|
||||
Returns {:ok, total_damage} or {:error, reason}
|
||||
"""
|
||||
def apply_attack(attack_info, character_pid, map_pid, channel_id) do
|
||||
with {:ok, character} <- Character.get_state(character_pid),
|
||||
{:ok, map_data} <- Map.get_monsters(map_pid) do
|
||||
# Check if character is alive
|
||||
if not character.alive? do
|
||||
Logger.warning("Character #{character.name} attacking while dead")
|
||||
{:error, :attacking_while_dead}
|
||||
else
|
||||
# Calculate max damage per monster
|
||||
max_damage_per_monster = calculate_max_damage(character, attack_info)
|
||||
|
||||
# Apply damage to each targeted monster
|
||||
total_damage =
|
||||
Enum.reduce(attack_info.all_damage, 0, fn damage_entry, acc_total ->
|
||||
apply_damage_to_monster(
|
||||
damage_entry,
|
||||
attack_info,
|
||||
character,
|
||||
map_pid,
|
||||
channel_id,
|
||||
max_damage_per_monster
|
||||
) + acc_total
|
||||
end)
|
||||
|
||||
Logger.debug("Attack applied: #{total_damage} total damage to #{length(attack_info.all_damage)} monsters")
|
||||
|
||||
# Broadcast attack packet to other players
|
||||
broadcast_attack(attack_info, character, map_pid, channel_id)
|
||||
|
||||
{:ok, total_damage}
|
||||
end
|
||||
else
|
||||
error ->
|
||||
Logger.error("Failed to apply attack: #{inspect(error)}")
|
||||
{:error, :apply_failed}
|
||||
end
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Private Helper Functions
|
||||
# ============================================================================
|
||||
|
||||
defp apply_damage_to_monster(damage_entry, attack_info, character, map_pid, channel_id, max_damage) do
|
||||
# Calculate total damage to this monster
|
||||
total_damage =
|
||||
Enum.reduce(damage_entry.damages, 0, fn {damage, _crit}, acc ->
|
||||
# Cap damage at max allowed
|
||||
capped_damage = min(damage, trunc(max_damage))
|
||||
acc + capped_damage
|
||||
end)
|
||||
|
||||
if total_damage > 0 do
|
||||
# Apply damage via Map module
|
||||
case Map.damage_monster(map_pid, damage_entry.mob_oid, character.id, total_damage) do
|
||||
{:ok, :killed} ->
|
||||
Logger.debug("Monster #{damage_entry.mob_oid} killed by #{character.name}")
|
||||
total_damage
|
||||
|
||||
{:ok, :damaged} ->
|
||||
total_damage
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warning("Failed to damage monster #{damage_entry.mob_oid}: #{inspect(reason)}")
|
||||
0
|
||||
end
|
||||
else
|
||||
0
|
||||
end
|
||||
end
|
||||
|
||||
defp calculate_max_damage(character, attack_info) do
|
||||
# Base damage calculation
|
||||
# TODO: Implement full damage formula with stats, weapon attack, skill damage, etc.
|
||||
# For now, use a simple formula based on level and stats
|
||||
|
||||
base_damage =
|
||||
cond do
|
||||
# Magic attack
|
||||
attack_info.attack_type == :magic ->
|
||||
character.stats.int * 5 + character.stats.luk + character.level * 10
|
||||
|
||||
# Ranged attack
|
||||
attack_info.attack_type == :ranged or
|
||||
attack_info.attack_type == :ranged_with_shadowpartner ->
|
||||
character.stats.dex * 5 + character.stats.str + character.level * 10
|
||||
|
||||
# Melee attack
|
||||
true ->
|
||||
character.stats.str * 5 + character.stats.dex + character.level * 10
|
||||
end
|
||||
|
||||
# Apply skill multiplier if skill is used
|
||||
skill_multiplier =
|
||||
if attack_info.skill > 0 do
|
||||
# TODO: Get actual skill damage multiplier from SkillFactory
|
||||
2.0
|
||||
else
|
||||
1.0
|
||||
end
|
||||
|
||||
# Calculate max damage per hit
|
||||
max_damage_per_hit = base_damage * skill_multiplier
|
||||
|
||||
# For now, return a reasonable cap
|
||||
# TODO: Implement actual damage cap from config
|
||||
min(max_damage_per_hit, 999_999)
|
||||
end
|
||||
|
||||
defp broadcast_attack(attack_info, character, map_pid, channel_id) do
|
||||
# Build attack packet based on attack type
|
||||
attack_packet =
|
||||
case attack_info.attack_type do
|
||||
:melee ->
|
||||
build_melee_attack_packet(attack_info, character)
|
||||
|
||||
:ranged ->
|
||||
build_ranged_attack_packet(attack_info, character)
|
||||
|
||||
:magic ->
|
||||
build_magic_attack_packet(attack_info, character)
|
||||
|
||||
_ ->
|
||||
build_melee_attack_packet(attack_info, character)
|
||||
end
|
||||
|
||||
# Broadcast to all players on map except attacker
|
||||
Map.broadcast_except(
|
||||
character.map_id,
|
||||
channel_id,
|
||||
character.id,
|
||||
attack_packet
|
||||
)
|
||||
end
|
||||
|
||||
defp build_melee_attack_packet(attack_info, character) do
|
||||
packet =
|
||||
Out.new(Opcodes.lp_close_range_attack())
|
||||
|> Out.encode_int(character.id)
|
||||
|> Out.encode_byte(attack_info.tbyte)
|
||||
|> Out.encode_byte(character.stats.skill_level)
|
||||
|> Out.encode_int(attack_info.skill)
|
||||
|> Out.encode_byte(attack_info.display &&& 0xFF)
|
||||
|> Out.encode_byte((attack_info.display >>> 8) &&& 0xFF)
|
||||
|> Out.encode_byte(attack_info.speed)
|
||||
|> Out.encode_int(attack_info.last_attack_tick)
|
||||
|
||||
# Encode damage for each target
|
||||
packet =
|
||||
Enum.reduce(attack_info.all_damage, packet, fn damage_entry, acc ->
|
||||
acc
|
||||
|> Out.encode_int(damage_entry.mob_oid)
|
||||
|> encode_damage_hits(damage_entry.damages)
|
||||
end)
|
||||
|
||||
packet
|
||||
|> Out.encode_short(attack_info.position.x)
|
||||
|> Out.encode_short(attack_info.position.y)
|
||||
|> Out.to_data()
|
||||
end
|
||||
|
||||
defp build_ranged_attack_packet(attack_info, character) do
|
||||
packet =
|
||||
Out.new(Opcodes.lp_ranged_attack())
|
||||
|> Out.encode_int(character.id)
|
||||
|> Out.encode_byte(attack_info.tbyte)
|
||||
|> Out.encode_byte(character.stats.skill_level)
|
||||
|> Out.encode_int(attack_info.skill)
|
||||
|> Out.encode_byte(attack_info.display &&& 0xFF)
|
||||
|> Out.encode_byte((attack_info.display >>> 8) &&& 0xFF)
|
||||
|> Out.encode_byte(attack_info.speed)
|
||||
|> Out.encode_int(attack_info.last_attack_tick)
|
||||
|
||||
# Encode damage for each target
|
||||
packet =
|
||||
Enum.reduce(attack_info.all_damage, packet, fn damage_entry, acc ->
|
||||
acc
|
||||
|> Out.encode_int(damage_entry.mob_oid)
|
||||
|> encode_damage_hits(damage_entry.damages)
|
||||
end)
|
||||
|
||||
packet
|
||||
|> Out.encode_short(attack_info.position.x)
|
||||
|> Out.encode_short(attack_info.position.y)
|
||||
|> Out.to_data()
|
||||
end
|
||||
|
||||
defp build_magic_attack_packet(attack_info, character) do
|
||||
packet =
|
||||
Out.new(Opcodes.lp_magic_attack())
|
||||
|> Out.encode_int(character.id)
|
||||
|> Out.encode_byte(attack_info.tbyte)
|
||||
|> Out.encode_byte(character.stats.skill_level)
|
||||
|> Out.encode_int(attack_info.skill)
|
||||
|> Out.encode_byte(attack_info.display &&& 0xFF)
|
||||
|> Out.encode_byte((attack_info.display >>> 8) &&& 0xFF)
|
||||
|> Out.encode_byte(attack_info.speed)
|
||||
|> Out.encode_int(attack_info.last_attack_tick)
|
||||
|
||||
# Encode damage for each target
|
||||
packet =
|
||||
Enum.reduce(attack_info.all_damage, packet, fn damage_entry, acc ->
|
||||
acc
|
||||
|> Out.encode_int(damage_entry.mob_oid)
|
||||
|> encode_damage_hits(damage_entry.damages)
|
||||
end)
|
||||
|
||||
packet
|
||||
|> Out.encode_short(attack_info.position.x)
|
||||
|> Out.encode_short(attack_info.position.y)
|
||||
|> Out.to_data()
|
||||
end
|
||||
|
||||
defp encode_damage_hits(packet, damages) do
|
||||
Enum.reduce(damages, packet, fn {damage, _crit}, acc ->
|
||||
acc |> Out.encode_int(damage)
|
||||
end)
|
||||
end
|
||||
end
|
||||
200
lib/odinsea/game/drop.ex
Normal file
200
lib/odinsea/game/drop.ex
Normal file
@@ -0,0 +1,200 @@
|
||||
defmodule Odinsea.Game.Drop do
|
||||
@moduledoc """
|
||||
Represents a drop item on a map.
|
||||
Ported from Java server.maps.MapleMapItem
|
||||
|
||||
Drops can be:
|
||||
- Item drops (equipment, use, setup, etc items)
|
||||
- Meso drops (gold/money)
|
||||
|
||||
Drop ownership determines who can loot:
|
||||
- Type 0: Timeout for non-owner only
|
||||
- Type 1: Timeout for non-owner's party
|
||||
- Type 2: Free-for-all (FFA)
|
||||
- Type 3: Explosive/FFA (instant FFA)
|
||||
"""
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
oid: integer(),
|
||||
item_id: integer(),
|
||||
quantity: integer(),
|
||||
meso: integer(),
|
||||
owner_id: integer(),
|
||||
drop_type: integer(),
|
||||
position: %{x: integer(), y: integer()},
|
||||
source_position: %{x: integer(), y: integer()} | nil,
|
||||
quest_id: integer(),
|
||||
player_drop: boolean(),
|
||||
individual_reward: boolean(),
|
||||
picked_up: boolean(),
|
||||
created_at: integer(),
|
||||
expire_time: integer() | nil,
|
||||
public_time: integer() | nil,
|
||||
dropper_oid: integer() | nil
|
||||
}
|
||||
|
||||
defstruct [
|
||||
:oid,
|
||||
:item_id,
|
||||
:quantity,
|
||||
:meso,
|
||||
:owner_id,
|
||||
:drop_type,
|
||||
:position,
|
||||
:source_position,
|
||||
:quest_id,
|
||||
:player_drop,
|
||||
:individual_reward,
|
||||
:picked_up,
|
||||
:created_at,
|
||||
:expire_time,
|
||||
:public_time,
|
||||
:dropper_oid
|
||||
]
|
||||
|
||||
# Default drop expiration times (milliseconds)
|
||||
@default_expire_time 120_000 # 2 minutes
|
||||
@default_public_time 60_000 # 1 minute until FFA
|
||||
|
||||
@doc """
|
||||
Creates a new item drop.
|
||||
"""
|
||||
def new_item_drop(oid, item_id, quantity, owner_id, position, opts \\ []) do
|
||||
drop_type = Keyword.get(opts, :drop_type, 0)
|
||||
quest_id = Keyword.get(opts, :quest_id, -1)
|
||||
individual_reward = Keyword.get(opts, :individual_reward, false)
|
||||
dropper_oid = Keyword.get(opts, :dropper_oid, nil)
|
||||
source_position = Keyword.get(opts, :source_position, nil)
|
||||
now = System.system_time(:millisecond)
|
||||
|
||||
%__MODULE__{
|
||||
oid: oid,
|
||||
item_id: item_id,
|
||||
quantity: quantity,
|
||||
meso: 0,
|
||||
owner_id: owner_id,
|
||||
drop_type: drop_type,
|
||||
position: position,
|
||||
source_position: source_position,
|
||||
quest_id: quest_id,
|
||||
player_drop: false,
|
||||
individual_reward: individual_reward,
|
||||
picked_up: false,
|
||||
created_at: now,
|
||||
expire_time: now + @default_expire_time,
|
||||
public_time: if(drop_type < 2, do: now + @default_public_time, else: 0),
|
||||
dropper_oid: dropper_oid
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Creates a new meso drop.
|
||||
"""
|
||||
def new_meso_drop(oid, amount, owner_id, position, opts \\ []) do
|
||||
drop_type = Keyword.get(opts, :drop_type, 0)
|
||||
individual_reward = Keyword.get(opts, :individual_reward, false)
|
||||
dropper_oid = Keyword.get(opts, :dropper_oid, nil)
|
||||
source_position = Keyword.get(opts, :source_position, nil)
|
||||
now = System.system_time(:millisecond)
|
||||
|
||||
%__MODULE__{
|
||||
oid: oid,
|
||||
item_id: 0,
|
||||
quantity: 0,
|
||||
meso: amount,
|
||||
owner_id: owner_id,
|
||||
drop_type: drop_type,
|
||||
position: position,
|
||||
source_position: source_position,
|
||||
quest_id: -1,
|
||||
player_drop: false,
|
||||
individual_reward: individual_reward,
|
||||
picked_up: false,
|
||||
created_at: now,
|
||||
expire_time: now + @default_expire_time,
|
||||
public_time: if(drop_type < 2, do: now + @default_public_time, else: 0),
|
||||
dropper_oid: dropper_oid
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Marks the drop as picked up.
|
||||
"""
|
||||
def mark_picked_up(%__MODULE__{} = drop) do
|
||||
%{drop | picked_up: true}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if the drop should expire based on current time.
|
||||
"""
|
||||
def should_expire?(%__MODULE__{} = drop, now) do
|
||||
not drop.picked_up and drop.expire_time != nil and drop.expire_time < now
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if the drop has become public (FFA) based on current time.
|
||||
"""
|
||||
def is_public_time?(%__MODULE__{} = drop, now) do
|
||||
not drop.picked_up and drop.public_time != nil and drop.public_time < now
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if a drop is visible to a specific character.
|
||||
Considers quest requirements and individual rewards.
|
||||
"""
|
||||
def visible_to?(%__MODULE__{} = drop, character_id, _quest_status) do
|
||||
# Individual rewards only visible to owner
|
||||
if drop.individual_reward do
|
||||
drop.owner_id == character_id
|
||||
else
|
||||
true
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if this is a meso drop.
|
||||
"""
|
||||
def meso?(%__MODULE__{} = drop) do
|
||||
drop.meso > 0
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the display ID (item_id for items, meso amount for meso).
|
||||
"""
|
||||
def display_id(%__MODULE__{} = drop) do
|
||||
if drop.meso > 0 do
|
||||
drop.meso
|
||||
else
|
||||
drop.item_id
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if a character can loot this drop.
|
||||
"""
|
||||
def can_loot?(%__MODULE__{} = drop, character_id, now) do
|
||||
# If already picked up, can't loot
|
||||
if drop.picked_up do
|
||||
false
|
||||
else
|
||||
# Check ownership rules based on drop type
|
||||
case drop.drop_type do
|
||||
0 ->
|
||||
# Timeout for non-owner only
|
||||
drop.owner_id == character_id or is_public_time?(drop, now)
|
||||
1 ->
|
||||
# Timeout for non-owner's party (simplified - treat as FFA after timeout)
|
||||
drop.owner_id == character_id or is_public_time?(drop, now)
|
||||
2 ->
|
||||
# FFA
|
||||
true
|
||||
3 ->
|
||||
# Explosive/FFA (instant FFA)
|
||||
true
|
||||
_ ->
|
||||
# Default to owner-only
|
||||
drop.owner_id == character_id
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
280
lib/odinsea/game/drop_system.ex
Normal file
280
lib/odinsea/game/drop_system.ex
Normal file
@@ -0,0 +1,280 @@
|
||||
defmodule Odinsea.Game.DropSystem do
|
||||
@moduledoc """
|
||||
Drop creation and management system.
|
||||
Ported from Java server.maps.MapleMap.spawnMobDrop()
|
||||
|
||||
This module handles:
|
||||
- Creating drops when monsters die
|
||||
- Calculating drop positions
|
||||
- Managing drop ownership
|
||||
- Drop expiration
|
||||
"""
|
||||
|
||||
alias Odinsea.Game.{Drop, DropTable, Item, ItemInfo}
|
||||
alias Odinsea.Game.LifeFactory
|
||||
|
||||
require Logger
|
||||
|
||||
# Default drop rate multiplier
|
||||
@default_drop_rate 1.0
|
||||
|
||||
# Drop type constants
|
||||
@drop_type_owner_timeout 0 # Timeout for non-owner
|
||||
@drop_type_party_timeout 1 # Timeout for non-owner's party
|
||||
@drop_type_ffa 2 # Free for all
|
||||
@drop_type_explosive 3 # Explosive (instant FFA)
|
||||
|
||||
@doc """
|
||||
Creates drops for a killed monster.
|
||||
Returns a list of Drop structs.
|
||||
"""
|
||||
@spec create_monster_drops(integer(), integer(), map(), integer(), float()) :: [Drop.t()]
|
||||
def create_monster_drops(mob_id, owner_id, position, next_oid, drop_rate_multiplier \\ @default_drop_rate) do
|
||||
# Get monster stats
|
||||
stats = LifeFactory.get_monster_stats(mob_id)
|
||||
|
||||
if stats == nil do
|
||||
Logger.warning("Cannot create drops - monster stats not found for mob_id #{mob_id}")
|
||||
[]
|
||||
else
|
||||
# Calculate what should drop
|
||||
calculated_drops = DropTable.calculate_drops(mob_id, drop_rate_multiplier)
|
||||
|
||||
# Create Drop structs
|
||||
{drops, _next_oid} =
|
||||
Enum.reduce(calculated_drops, {[], next_oid}, fn drop_data, {acc, oid} ->
|
||||
case create_drop(drop_data, owner_id, position, oid, stats) do
|
||||
nil -> {acc, oid}
|
||||
drop -> {[drop | acc], oid + 1}
|
||||
end
|
||||
end)
|
||||
|
||||
Enum.reverse(drops)
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Creates a single drop from calculated drop data.
|
||||
"""
|
||||
@spec create_drop({atom(), integer(), integer()}, integer(), map(), integer(), map()) :: Drop.t() | nil
|
||||
def create_drop({:meso, amount, _}, owner_id, position, oid, stats) do
|
||||
# Calculate drop position (small random offset from monster)
|
||||
drop_position = calculate_drop_position(position)
|
||||
|
||||
# Determine drop type based on monster
|
||||
drop_type =
|
||||
cond do
|
||||
stats.boss and not stats.party_bonus -> @drop_type_explosive
|
||||
stats.party_bonus -> @drop_type_party_timeout
|
||||
true -> @drop_type_ffa
|
||||
end
|
||||
|
||||
Drop.new_meso_drop(oid, amount, owner_id, drop_position,
|
||||
drop_type: drop_type,
|
||||
dropper_oid: nil,
|
||||
source_position: position
|
||||
)
|
||||
end
|
||||
|
||||
def create_drop({:item, item_id, quantity}, owner_id, position, oid, stats) do
|
||||
# Validate item exists
|
||||
if ItemInfo.item_exists?(item_id) do
|
||||
# Calculate drop position
|
||||
drop_position = calculate_drop_position(position)
|
||||
|
||||
# Determine drop type
|
||||
drop_type =
|
||||
cond do
|
||||
stats.boss and not stats.party_bonus -> @drop_type_explosive
|
||||
stats.party_bonus -> @drop_type_party_timeout
|
||||
true -> @drop_type_ffa
|
||||
end
|
||||
|
||||
Drop.new_item_drop(oid, item_id, quantity, owner_id, drop_position,
|
||||
drop_type: drop_type,
|
||||
dropper_oid: nil,
|
||||
source_position: position
|
||||
)
|
||||
else
|
||||
Logger.debug("Item #{item_id} not found, skipping drop")
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Creates an item drop from a player's inventory.
|
||||
Used when a player drops an item.
|
||||
"""
|
||||
@spec create_player_drop(Item.t(), integer(), map(), integer()) :: Drop.t()
|
||||
def create_player_drop(item, owner_id, position, oid) do
|
||||
drop_position = calculate_drop_position(position)
|
||||
|
||||
Drop.new_item_drop(oid, item.item_id, item.quantity, owner_id, drop_position,
|
||||
drop_type: @drop_type_ffa, # Player drops are always FFA
|
||||
player_drop: true
|
||||
)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Creates an equipment drop with randomized stats.
|
||||
"""
|
||||
@spec create_equipment_drop(integer(), integer(), map(), integer(), float()) :: Drop.t() | nil
|
||||
def create_equipment_drop(item_id, owner_id, position, oid, _drop_rate_multiplier \\ 1.0) do
|
||||
# Check if item is equipment
|
||||
case ItemInfo.get_inventory_type(item_id) do
|
||||
:equip ->
|
||||
# Get equipment stats
|
||||
equip = ItemInfo.create_equip(item_id)
|
||||
|
||||
if equip do
|
||||
drop_position = calculate_drop_position(position)
|
||||
|
||||
Drop.new_item_drop(oid, item_id, 1, owner_id, drop_position,
|
||||
drop_type: @drop_type_ffa
|
||||
)
|
||||
else
|
||||
nil
|
||||
end
|
||||
|
||||
_ ->
|
||||
# Not equipment, create regular item drop
|
||||
create_drop({:item, item_id, 1}, owner_id, position, oid, %{})
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Calculates a drop position near the source position.
|
||||
"""
|
||||
@spec calculate_drop_position(map()) :: %{x: integer(), y: integer()}
|
||||
def calculate_drop_position(%{x: x, y: y}) do
|
||||
# Random offset within range
|
||||
offset_x = :rand.uniform(80) - 40 # -40 to +40
|
||||
offset_y = :rand.uniform(20) - 10 # -10 to +10
|
||||
|
||||
%{
|
||||
x: x + offset_x,
|
||||
y: y + offset_y
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks and handles drop expiration.
|
||||
Returns updated drops list with expired ones marked.
|
||||
"""
|
||||
@spec check_expiration([Drop.t()], integer()) :: [Drop.t()]
|
||||
def check_expiration(drops, now) do
|
||||
Enum.map(drops, fn drop ->
|
||||
if Drop.should_expire?(drop, now) do
|
||||
Drop.mark_picked_up(drop)
|
||||
else
|
||||
drop
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Filters out expired and picked up drops.
|
||||
"""
|
||||
@spec cleanup_drops([Drop.t()]) :: [Drop.t()]
|
||||
def cleanup_drops(drops) do
|
||||
Enum.reject(drops, fn drop ->
|
||||
drop.picked_up
|
||||
end)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Attempts to pick up a drop.
|
||||
Returns {:ok, drop} if successful, {:error, reason} if not.
|
||||
"""
|
||||
@spec pickup_drop(Drop.t(), integer(), integer()) :: {:ok, Drop.t()} | {:error, atom()}
|
||||
def pickup_drop(drop, character_id, now) do
|
||||
cond do
|
||||
drop.picked_up ->
|
||||
{:error, :already_picked_up}
|
||||
|
||||
not Drop.can_loot?(drop, character_id, now) ->
|
||||
{:error, :not_owner}
|
||||
|
||||
true ->
|
||||
{:ok, Drop.mark_picked_up(drop)}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets all visible drops for a character.
|
||||
"""
|
||||
@spec get_visible_drops([Drop.t()], integer(), map()) :: [Drop.t()]
|
||||
def get_visible_drops(drops, character_id, quest_status) do
|
||||
Enum.filter(drops, fn drop ->
|
||||
Drop.visible_to?(drop, character_id, quest_status)
|
||||
end)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Determines drop ownership type based on damage contribution.
|
||||
"""
|
||||
@spec determine_drop_type([{integer(), integer()}], integer()) :: integer()
|
||||
def determine_drop_type(attackers, _killer_id) do
|
||||
# If only one attacker, owner-only
|
||||
# If multiple attackers, party/FFA based on damage distribution
|
||||
|
||||
case length(attackers) do
|
||||
1 -> @drop_type_owner_timeout
|
||||
_ -> @drop_type_party_timeout
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Creates drops with specific ownership rules.
|
||||
Used for special drops like event rewards.
|
||||
"""
|
||||
@spec create_special_drop(integer(), integer(), integer(), map(), integer(), keyword()) :: Drop.t()
|
||||
def create_special_drop(item_id, quantity, owner_id, position, oid, opts \\ []) do
|
||||
drop_type = Keyword.get(opts, :drop_type, @drop_type_explosive)
|
||||
quest_id = Keyword.get(opts, :quest_id, -1)
|
||||
individual = Keyword.get(opts, :individual_reward, false)
|
||||
|
||||
Drop.new_item_drop(oid, item_id, quantity, owner_id, position,
|
||||
drop_type: drop_type,
|
||||
quest_id: quest_id,
|
||||
individual_reward: individual
|
||||
)
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Global Drop System (Global Drops apply to all monsters)
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Creates global drops for a monster kill.
|
||||
These are additional drops that can drop from any monster.
|
||||
"""
|
||||
@spec create_global_drops(integer(), map(), integer(), float()) :: [Drop.t()]
|
||||
def create_global_drops(owner_id, position, next_oid, drop_rate_multiplier \\ @default_drop_rate) do
|
||||
global_entries = DropTable.get_global_drops()
|
||||
|
||||
{drops, _next_oid} =
|
||||
Enum.reduce(global_entries, {[], next_oid}, fn entry, {acc, oid} ->
|
||||
case DropTable.roll_drop(entry, drop_rate_multiplier) do
|
||||
nil ->
|
||||
{acc, oid}
|
||||
|
||||
{item_id, quantity} ->
|
||||
if ItemInfo.item_exists?(item_id) do
|
||||
drop_position = calculate_drop_position(position)
|
||||
|
||||
drop = Drop.new_item_drop(oid, item_id, quantity, owner_id, drop_position,
|
||||
drop_type: entry.drop_type,
|
||||
quest_id: entry.questid
|
||||
)
|
||||
|
||||
{[drop | acc], oid + 1}
|
||||
else
|
||||
{acc, oid}
|
||||
end
|
||||
end
|
||||
end)
|
||||
|
||||
Enum.reverse(drops)
|
||||
end
|
||||
end
|
||||
321
lib/odinsea/game/drop_table.ex
Normal file
321
lib/odinsea/game/drop_table.ex
Normal file
@@ -0,0 +1,321 @@
|
||||
defmodule Odinsea.Game.DropTable do
|
||||
@moduledoc """
|
||||
Manages drop tables for monsters.
|
||||
Ported from Java server.life.MonsterDropEntry and MapleMonsterInformationProvider
|
||||
|
||||
Drop tables define what items a monster can drop when killed.
|
||||
Each drop entry includes:
|
||||
- item_id: The item to drop
|
||||
- chance: Drop rate (out of 1,000,000 for most items)
|
||||
- min_quantity: Minimum quantity
|
||||
- max_quantity: Maximum quantity
|
||||
- quest_id: Quest requirement (-1 = no quest)
|
||||
"""
|
||||
|
||||
alias Odinsea.Game.LifeFactory
|
||||
|
||||
require Logger
|
||||
|
||||
@typedoc "A single drop entry"
|
||||
@type drop_entry :: %{
|
||||
item_id: integer(),
|
||||
chance: integer(),
|
||||
min_quantity: integer(),
|
||||
max_quantity: integer(),
|
||||
quest_id: integer()
|
||||
}
|
||||
|
||||
@typedoc "Drop table for a monster"
|
||||
@type drop_table :: [drop_entry()]
|
||||
|
||||
# Default drop rates for different item categories
|
||||
@equip_drop_rate 0.05 # 5% base for equipment
|
||||
@use_drop_rate 0.10 # 10% for use items
|
||||
@etc_drop_rate 0.15 # 15% for etc items
|
||||
@setup_drop_rate 0.02 # 2% for setup items
|
||||
@cash_drop_rate 0.01 # 1% for cash items
|
||||
|
||||
# Meso drop configuration
|
||||
@meso_chance_normal 400_000 # 40% base for normal mobs
|
||||
@meso_chance_boss 1_000_000 # 100% for bosses
|
||||
|
||||
@doc """
|
||||
Gets the drop table for a monster.
|
||||
"""
|
||||
@spec get_drops(integer()) :: drop_table()
|
||||
def get_drops(mob_id) do
|
||||
# Try to get from cache first
|
||||
case lookup_drop_table(mob_id) do
|
||||
nil -> load_and_cache_drops(mob_id)
|
||||
drops -> drops
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Calculates drops for a monster kill.
|
||||
Returns a list of {item_id, quantity} tuples or {:meso, amount}.
|
||||
"""
|
||||
@spec calculate_drops(integer(), integer(), map()) :: [{:item | :meso, integer(), integer()}]
|
||||
def calculate_drops(mob_id, drop_rate_multiplier, _opts \\ %{}) do
|
||||
drops = get_drops(mob_id)
|
||||
|
||||
# Get monster stats for meso calculation
|
||||
stats = LifeFactory.get_monster_stats(mob_id)
|
||||
|
||||
results =
|
||||
if stats do
|
||||
# Check if meso drops are disabled for this monster type
|
||||
should_drop_mesos = should_drop_mesos?(stats)
|
||||
|
||||
# Add meso drops if applicable
|
||||
meso_drops =
|
||||
if should_drop_mesos do
|
||||
calculate_meso_drops(stats, drop_rate_multiplier)
|
||||
else
|
||||
[]
|
||||
end
|
||||
|
||||
# Roll for each item drop
|
||||
item_drops =
|
||||
Enum.flat_map(drops, fn entry ->
|
||||
case roll_drop(entry, drop_rate_multiplier) do
|
||||
nil -> []
|
||||
{item_id, quantity} -> [{:item, item_id, quantity}]
|
||||
end
|
||||
end)
|
||||
|
||||
meso_drops ++ item_drops
|
||||
else
|
||||
[]
|
||||
end
|
||||
|
||||
# Limit total drops to prevent flooding
|
||||
Enum.take(results, 10)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Rolls for a single drop entry.
|
||||
Returns {item_id, quantity} if successful, nil if failed.
|
||||
"""
|
||||
@spec roll_drop(drop_entry(), integer() | float()) :: {integer(), integer()} | nil
|
||||
def roll_drop(entry, multiplier) do
|
||||
# Calculate adjusted chance
|
||||
base_chance = entry.chance
|
||||
adjusted_chance = trunc(base_chance * multiplier)
|
||||
|
||||
# Roll (1,000,000 = 100%)
|
||||
roll = :rand.uniform(1_000_000)
|
||||
|
||||
if roll <= adjusted_chance do
|
||||
# Determine quantity
|
||||
quantity =
|
||||
if entry.max_quantity > entry.min_quantity do
|
||||
entry.min_quantity + :rand.uniform(entry.max_quantity - entry.min_quantity + 1) - 1
|
||||
else
|
||||
entry.min_quantity
|
||||
end
|
||||
|
||||
{entry.item_id, max(1, quantity)}
|
||||
else
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Calculates meso drops for a monster.
|
||||
"""
|
||||
@spec calculate_meso_drops(map(), integer() | float()) :: [{:meso, integer(), integer()}]
|
||||
def calculate_meso_drops(stats, drop_rate_multiplier) do
|
||||
# Determine number of meso drops based on monster type
|
||||
num_drops =
|
||||
cond do
|
||||
stats.boss and not stats.party_bonus -> 2
|
||||
stats.party_bonus -> 1
|
||||
true -> 1
|
||||
end
|
||||
|
||||
# Calculate max meso amount
|
||||
level = stats.level
|
||||
|
||||
# Formula: level * (level / 10) = max
|
||||
# Min = 0.66 * max
|
||||
divided = if level < 100, do: max(level, 10) / 10.0, else: level / 10.0
|
||||
|
||||
max_amount =
|
||||
if stats.boss and not stats.party_bonus do
|
||||
level * level
|
||||
else
|
||||
trunc(level * :math.ceil(level / divided))
|
||||
end
|
||||
|
||||
min_amount = trunc(0.66 * max_amount)
|
||||
|
||||
# Roll for each meso drop
|
||||
base_chance = if stats.boss and not stats.party_bonus, do: @meso_chance_boss, else: @meso_chance_normal
|
||||
adjusted_chance = trunc(base_chance * drop_rate_multiplier)
|
||||
|
||||
Enum.flat_map(1..num_drops, fn _ ->
|
||||
roll = :rand.uniform(1_000_000)
|
||||
|
||||
if roll <= adjusted_chance do
|
||||
amount = min_amount + :rand.uniform(max(1, max_amount - min_amount + 1)) - 1
|
||||
[{:meso, max(1, amount), 1}]
|
||||
else
|
||||
[]
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets global drops that apply to all monsters.
|
||||
"""
|
||||
@spec get_global_drops() :: drop_table()
|
||||
def get_global_drops do
|
||||
# Global drops from database (would be loaded from drop_data_global table)
|
||||
# For now, return empty list
|
||||
[]
|
||||
end
|
||||
|
||||
@doc """
|
||||
Clears the drop table cache.
|
||||
"""
|
||||
def clear_cache do
|
||||
:ets.delete_all_objects(:drop_table_cache)
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Private Functions
|
||||
# ============================================================================
|
||||
|
||||
defp lookup_drop_table(mob_id) do
|
||||
case :ets.lookup(:drop_table_cache, mob_id) do
|
||||
[{^mob_id, drops}] -> drops
|
||||
[] -> nil
|
||||
end
|
||||
end
|
||||
|
||||
defp load_and_cache_drops(mob_id) do
|
||||
drops = load_drops_from_source(mob_id)
|
||||
:ets.insert(:drop_table_cache, {mob_id, drops})
|
||||
drops
|
||||
end
|
||||
|
||||
defp load_drops_from_source(mob_id) do
|
||||
# In a full implementation, this would:
|
||||
# 1. Query drop_data_final_v2 table
|
||||
# 2. Apply chance adjustments based on item type
|
||||
# 3. Return processed drops
|
||||
|
||||
# For now, return fallback drops based on monster level
|
||||
generate_fallback_drops(mob_id)
|
||||
end
|
||||
|
||||
defp generate_fallback_drops(mob_id) do
|
||||
# Get monster stats to determine level-appropriate drops
|
||||
case LifeFactory.get_monster_stats(mob_id) do
|
||||
nil -> []
|
||||
stats -> generate_level_drops(stats)
|
||||
end
|
||||
end
|
||||
|
||||
defp generate_level_drops(stats) do
|
||||
level = stats.level
|
||||
|
||||
# Generate appropriate drops based on monster level
|
||||
cond do
|
||||
level <= 10 ->
|
||||
# Beginner drops
|
||||
[
|
||||
%{item_id: 2000000, chance: 50_000, min_quantity: 1, max_quantity: 3, quest_id: -1}, # Red Potion
|
||||
%{item_id: 2000001, chance: 50_000, min_quantity: 1, max_quantity: 3, quest_id: -1}, # Orange Potion
|
||||
%{item_id: 4000000, chance: 30_000, min_quantity: 1, max_quantity: 1, quest_id: -1}, # Snail Shell
|
||||
%{item_id: 4000001, chance: 30_000, min_quantity: 1, max_quantity: 1, quest_id: -1}, # Blue Snail Shell
|
||||
]
|
||||
|
||||
level <= 20 ->
|
||||
# Low level drops
|
||||
[
|
||||
%{item_id: 2000002, chance: 50_000, min_quantity: 1, max_quantity: 3, quest_id: -1}, # White Potion
|
||||
%{item_id: 2000003, chance: 50_000, min_quantity: 1, max_quantity: 3, quest_id: -1}, # Blue Potion
|
||||
%{item_id: 4000002, chance: 30_000, min_quantity: 1, max_quantity: 1, quest_id: -1}, # Red Snail Shell
|
||||
%{item_id: 4000010, chance: 30_000, min_quantity: 1, max_quantity: 1, quest_id: -1}, # Mushroom Spores
|
||||
]
|
||||
|
||||
level <= 40 ->
|
||||
# Mid level drops
|
||||
[
|
||||
%{item_id: 2000004, chance: 40_000, min_quantity: 1, max_quantity: 3, quest_id: -1}, # Elixir
|
||||
%{item_id: 2000005, chance: 40_000, min_quantity: 1, max_quantity: 3, quest_id: -1}, # Power Elixir
|
||||
%{item_id: 4000011, chance: 30_000, min_quantity: 1, max_quantity: 1, quest_id: -1}, # Pig's Head
|
||||
%{item_id: 4000012, chance: 30_000, min_quantity: 1, max_quantity: 1, quest_id: -1}, # Pig's Ribbon
|
||||
]
|
||||
|
||||
level <= 70 ->
|
||||
# Higher level drops
|
||||
[
|
||||
%{item_id: 2000005, chance: 50_000, min_quantity: 1, max_quantity: 5, quest_id: -1}, # Power Elixir
|
||||
%{item_id: 2040000, chance: 10_000, min_quantity: 1, max_quantity: 1, quest_id: -1}, # Scroll for Helmet
|
||||
%{item_id: 2040800, chance: 10_000, min_quantity: 1, max_quantity: 1, quest_id: -1}, # Scroll for Gloves
|
||||
]
|
||||
|
||||
true ->
|
||||
# High level drops
|
||||
[
|
||||
%{item_id: 2000005, chance: 60_000, min_quantity: 1, max_quantity: 10, quest_id: -1}, # Power Elixir
|
||||
%{item_id: 2044000, chance: 5_000, min_quantity: 1, max_quantity: 1, quest_id: -1}, # Scroll for Two-Handed Sword
|
||||
%{item_id: 2044100, chance: 5_000, min_quantity: 1, max_quantity: 1, quest_id: -1}, # Scroll for Two-Handed Axe
|
||||
]
|
||||
end
|
||||
end
|
||||
|
||||
defp should_drop_mesos?(stats) do
|
||||
# Don't drop mesos if:
|
||||
# - Monster has special properties (invincible, friendly, etc.)
|
||||
# - Monster is a fixed damage mob
|
||||
# - Monster is a special event mob
|
||||
|
||||
cond do
|
||||
stats.invincible -> false
|
||||
stats.friendly -> false
|
||||
stats.fixed_damage > 0 -> false
|
||||
stats.remove_after > 0 -> false
|
||||
true -> true
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Initializes the drop table cache ETS table.
|
||||
Called during application startup.
|
||||
"""
|
||||
def init_cache do
|
||||
:ets.new(:drop_table_cache, [
|
||||
:set,
|
||||
:public,
|
||||
:named_table,
|
||||
read_concurrency: true,
|
||||
write_concurrency: true
|
||||
])
|
||||
|
||||
Logger.info("Drop table cache initialized")
|
||||
:ok
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# GenServer Implementation (for supervision)
|
||||
# ============================================================================
|
||||
|
||||
use GenServer
|
||||
|
||||
@doc """
|
||||
Starts the DropTable cache manager.
|
||||
"""
|
||||
def start_link(_opts) do
|
||||
GenServer.start_link(__MODULE__, [], name: __MODULE__)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def init(_) do
|
||||
init_cache()
|
||||
{:ok, %{}}
|
||||
end
|
||||
end
|
||||
444
lib/odinsea/game/event.ex
Normal file
444
lib/odinsea/game/event.ex
Normal file
@@ -0,0 +1,444 @@
|
||||
defmodule Odinsea.Game.Event do
|
||||
@moduledoc """
|
||||
Base behaviour and common functions for in-game events.
|
||||
Ported from Java `server.events.MapleEvent`.
|
||||
|
||||
Events are scheduled activities that players can participate in for rewards.
|
||||
Each event type has specific gameplay mechanics, maps, and win conditions.
|
||||
|
||||
## Event Lifecycle
|
||||
1. Schedule - Event is scheduled on a channel
|
||||
2. Registration - Players register/join the event
|
||||
3. Start - Event begins with gameplay
|
||||
4. Gameplay - Event-specific mechanics run
|
||||
5. Finish - Winners receive prizes, all players warped out
|
||||
6. Reset - Event state is cleared for next run
|
||||
|
||||
## Implemented Events
|
||||
- Coconut - Team-based coconut hitting competition
|
||||
- Fitness - Obstacle course race (4 stages)
|
||||
- OlaOla - Portal guessing game (3 stages)
|
||||
- OxQuiz - True/False quiz with position-based answers
|
||||
- Snowball - Team snowball rolling competition
|
||||
- Survival - Last-man-standing platform challenge
|
||||
"""
|
||||
|
||||
alias Odinsea.Game.Timer.EventTimer
|
||||
alias Odinsea.Game.Character
|
||||
|
||||
require Logger
|
||||
|
||||
# ============================================================================
|
||||
# Types
|
||||
# ============================================================================
|
||||
|
||||
@typedoc "Event type identifier"
|
||||
@type event_type :: :coconut | :coke_play | :fitness | :ola_ola | :ox_quiz | :survival | :snowball
|
||||
|
||||
@typedoc "Event state struct"
|
||||
@type t :: %__MODULE__{
|
||||
type: event_type(),
|
||||
channel_id: non_neg_integer(),
|
||||
map_ids: [non_neg_integer()],
|
||||
is_running: boolean(),
|
||||
player_count: non_neg_integer(),
|
||||
registered_players: MapSet.t(),
|
||||
schedules: [reference()]
|
||||
}
|
||||
|
||||
defstruct [
|
||||
:type,
|
||||
:channel_id,
|
||||
:map_ids,
|
||||
is_running: false,
|
||||
player_count: 0,
|
||||
registered_players: MapSet.new(),
|
||||
schedules: []
|
||||
]
|
||||
|
||||
# ============================================================================
|
||||
# Behaviour Callbacks
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Called when a player finishes the event (reaches end map).
|
||||
Override to implement finish logic (give prizes, achievements, etc.)
|
||||
"""
|
||||
@callback finished(t(), Character.t()) :: :ok
|
||||
|
||||
@doc """
|
||||
Called to start the event gameplay.
|
||||
Override to implement event start logic (timers, broadcasts, etc.)
|
||||
"""
|
||||
@callback start_event(t()) :: t()
|
||||
|
||||
@doc """
|
||||
Called when a player loads into an event map.
|
||||
Override to send event-specific packets (clock, instructions, etc.)
|
||||
Default implementation sends event instructions.
|
||||
"""
|
||||
@callback on_map_load(t(), Character.t()) :: :ok
|
||||
|
||||
@doc """
|
||||
Resets the event state for a new run.
|
||||
Override to reset event-specific state (scores, stages, etc.)
|
||||
"""
|
||||
@callback reset(t()) :: t()
|
||||
|
||||
@doc """
|
||||
Cleans up the event after it ends.
|
||||
Override to cancel timers and reset state.
|
||||
"""
|
||||
@callback unreset(t()) :: t()
|
||||
|
||||
@doc """
|
||||
Returns the map IDs associated with this event type.
|
||||
"""
|
||||
@callback map_ids() :: [non_neg_integer()]
|
||||
|
||||
# ============================================================================
|
||||
# Behaviour Definition
|
||||
# ============================================================================
|
||||
|
||||
@optional_callbacks [on_map_load: 2]
|
||||
|
||||
# ============================================================================
|
||||
# Common Functions
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Creates a new event struct.
|
||||
"""
|
||||
def new(type, channel_id, map_ids) do
|
||||
%__MODULE__{
|
||||
type: type,
|
||||
channel_id: channel_id,
|
||||
map_ids: map_ids,
|
||||
is_running: false,
|
||||
player_count: 0,
|
||||
registered_players: MapSet.new(),
|
||||
schedules: []
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Increments the player count. If count reaches 250, automatically starts the event.
|
||||
Returns updated event state.
|
||||
"""
|
||||
def increment_player_count(%__MODULE__{} = event) do
|
||||
new_count = event.player_count + 1
|
||||
event = %{event | player_count: new_count}
|
||||
|
||||
if new_count == 250 do
|
||||
Logger.info("Event #{event.type} reached 250 players, auto-starting...")
|
||||
set_event_auto_start(event.channel_id)
|
||||
end
|
||||
|
||||
event
|
||||
end
|
||||
|
||||
@doc """
|
||||
Registers a player for the event.
|
||||
"""
|
||||
def register_player(%__MODULE__{} = event, character_id) do
|
||||
%{event | registered_players: MapSet.put(event.registered_players, character_id)}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Unregisters a player from the event.
|
||||
"""
|
||||
def unregister_player(%__MODULE__{} = event, character_id) do
|
||||
%{event | registered_players: MapSet.delete(event.registered_players, character_id)}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if a player is registered for the event.
|
||||
"""
|
||||
def registered?(%__MODULE__{} = event, character_id) do
|
||||
MapSet.member?(event.registered_players, character_id)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the first map ID (entry map) for the event.
|
||||
"""
|
||||
def entry_map_id(%__MODULE__{map_ids: [first | _]}), do: first
|
||||
def entry_map_id(%__MODULE__{map_ids: []}), do: nil
|
||||
|
||||
@doc """
|
||||
Checks if the given map ID is part of this event.
|
||||
"""
|
||||
def event_map?(%__MODULE__{} = event, map_id) do
|
||||
map_id in event.map_ids
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the map index for the given map ID (0-based).
|
||||
Returns nil if map is not part of this event.
|
||||
"""
|
||||
def map_index(%__MODULE__{} = event, map_id) do
|
||||
case Enum.find_index(event.map_ids, &(&1 == map_id)) do
|
||||
nil -> nil
|
||||
index -> index
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Default implementation for on_map_load callback.
|
||||
Sends event instructions to the player if they're on an event map.
|
||||
"""
|
||||
def on_map_load_default(_event, character) do
|
||||
# Send event instructions packet
|
||||
# This would typically show instructions UI
|
||||
# For now, just log
|
||||
Logger.debug("Player #{character.name} loaded event map")
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Warps a character back to their saved location or default town.
|
||||
"""
|
||||
def warp_back(character) do
|
||||
# Get saved location or use default (Henesys: 104000000)
|
||||
return_map = character.saved_location || 104000000
|
||||
|
||||
# This would typically call Character.change_map/2
|
||||
# For now, just log
|
||||
Logger.info("Warping player #{character.name} back to map #{return_map}")
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gives a random event prize to a character.
|
||||
Prizes include: mesos, cash, vote points, fame, or items.
|
||||
"""
|
||||
def give_prize(character) do
|
||||
reward_type = random_reward_type()
|
||||
|
||||
case reward_type do
|
||||
:meso ->
|
||||
amount = :rand.uniform(9_000_000) + 1_000_000
|
||||
# Character.gain_meso(character, amount)
|
||||
Logger.info("Event prize: #{character.name} gained #{amount} mesos")
|
||||
|
||||
:cash ->
|
||||
amount = :rand.uniform(4000) + 1000
|
||||
# Character.modify_cash_points(character, amount)
|
||||
Logger.info("Event prize: #{character.name} gained #{amount} NX")
|
||||
|
||||
:vote_points ->
|
||||
# Character.add_vote_points(character, 1)
|
||||
Logger.info("Event prize: #{character.name} gained 1 vote point")
|
||||
|
||||
:fame ->
|
||||
# Character.add_fame(character, 10)
|
||||
Logger.info("Event prize: #{character.name} gained 10 fame")
|
||||
|
||||
:none ->
|
||||
Logger.info("Event prize: #{character.name} got no reward")
|
||||
|
||||
{:item, item_id, quantity} ->
|
||||
# Check inventory space and add item
|
||||
Logger.info("Event prize: #{character.name} got item #{item_id} x#{quantity}")
|
||||
end
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
# Random reward weights
|
||||
defp random_reward_type do
|
||||
roll = :rand.uniform(100)
|
||||
|
||||
cond do
|
||||
roll <= 25 -> :meso # 25% mesos
|
||||
roll <= 50 -> :cash # 25% cash
|
||||
roll <= 60 -> :vote_points # 10% vote points
|
||||
roll <= 70 -> :fame # 10% fame
|
||||
roll <= 75 -> :none # 5% no reward
|
||||
true -> random_item_reward() # 25% items
|
||||
end
|
||||
end
|
||||
|
||||
defp random_item_reward do
|
||||
# Item pool with quantities
|
||||
items = [
|
||||
{5062000, 1..3}, # Premium Miracle Cube (1-3)
|
||||
{5220000, 1..25}, # Gachapon Ticket (1-25)
|
||||
{4031307, 1..5}, # Piece of Statue (1-5)
|
||||
{5050000, 1..5}, # AP Reset Scroll (1-5)
|
||||
{2022121, 1..10}, # Chewy Rice Cake (1-10)
|
||||
]
|
||||
|
||||
{item_id, qty_range} = Enum.random(items)
|
||||
quantity = Enum.random(qty_range)
|
||||
|
||||
{:item, item_id, quantity}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Schedules the event to auto-start after player count threshold.
|
||||
"""
|
||||
def set_event_auto_start(channel_id) do
|
||||
# Schedule 30 second countdown before start
|
||||
EventTimer.schedule(
|
||||
fn ->
|
||||
broadcast_to_channel(channel_id, "The event will start in 30 seconds!")
|
||||
# Start clock countdown
|
||||
EventTimer.schedule(
|
||||
fn -> start_scheduled_event(channel_id) end,
|
||||
30_000
|
||||
)
|
||||
end,
|
||||
0
|
||||
)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Broadcasts a server notice to all players on a channel.
|
||||
"""
|
||||
def broadcast_to_channel(channel_id, message) do
|
||||
# This would call ChannelServer.broadcast
|
||||
Logger.info("[Channel #{channel_id}] Broadcast: #{message}")
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Broadcasts a packet to all players in all event maps.
|
||||
"""
|
||||
def broadcast_to_event(%__MODULE__{} = event, _packet) do
|
||||
# This would broadcast to all maps in event.map_ids
|
||||
Logger.debug("Broadcasting to event #{event.type} on channel #{event.channel_id}")
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Handles when a player loads into any map.
|
||||
Checks if they're on an event map and calls appropriate callbacks.
|
||||
"""
|
||||
def on_map_load(events, character, map_id, channel_id) do
|
||||
Enum.each(events, fn {event_type, event} ->
|
||||
if event.channel_id == channel_id and event.is_running do
|
||||
if map_id == 109050000 do
|
||||
# Finished map - call finished callback
|
||||
apply(event_module(event_type), :finished, [event, character])
|
||||
end
|
||||
|
||||
if event_map?(event, map_id) do
|
||||
# Event map - call on_map_load callback
|
||||
if function_exported?(event_module(event_type), :on_map_load, 2) do
|
||||
apply(event_module(event_type), :on_map_load, [event, character])
|
||||
else
|
||||
on_map_load_default(event, character)
|
||||
end
|
||||
|
||||
# If first map, increment player count
|
||||
if map_index(event, map_id) == 0 do
|
||||
increment_player_count(event)
|
||||
end
|
||||
end
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Handles manual event start command from a GM.
|
||||
"""
|
||||
def on_start_event(events, character, map_id) do
|
||||
Enum.each(events, fn {event_type, event} ->
|
||||
if event.is_running and event_map?(event, map_id) do
|
||||
new_event = apply(event_module(event_type), :start_event, [event])
|
||||
set_event(character.channel_id, -1)
|
||||
Logger.info("GM #{character.name} started event #{event_type}")
|
||||
new_event
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Schedules an event to run on a channel.
|
||||
Returns {:ok, updated_events} or {:error, reason}.
|
||||
"""
|
||||
def schedule_event(events, event_type, channel_id) do
|
||||
event = Map.get(events, event_type)
|
||||
|
||||
cond do
|
||||
is_nil(event) ->
|
||||
{:error, "Event type not found"}
|
||||
|
||||
event.is_running ->
|
||||
{:error, "The event is already running."}
|
||||
|
||||
true ->
|
||||
# Check if maps have players (simplified check)
|
||||
# In real implementation, check all map_ids
|
||||
entry_map = entry_map_id(event)
|
||||
|
||||
# Reset and activate event
|
||||
event = apply(event_module(event_type), :reset, [event])
|
||||
event = %{event | is_running: true}
|
||||
|
||||
# Broadcast to channel
|
||||
event_name = humanize_event_name(event_type)
|
||||
broadcast_to_channel(
|
||||
channel_id,
|
||||
"Hello! Let's play a #{event_name} event in channel #{channel_id}! " <>
|
||||
"Change to channel #{channel_id} and use @event command!"
|
||||
)
|
||||
|
||||
{:ok, Map.put(events, event_type, event)}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Sets the channel's active event map.
|
||||
"""
|
||||
def set_event(channel_id, map_id) do
|
||||
# This would update ChannelServer state
|
||||
Logger.debug("Set channel #{channel_id} event map to #{map_id}")
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Cancels all scheduled timers for an event.
|
||||
"""
|
||||
def cancel_schedules(%__MODULE__{schedules: schedules} = event) do
|
||||
Enum.each(schedules, fn ref ->
|
||||
EventTimer.cancel(ref)
|
||||
end)
|
||||
|
||||
%{event | schedules: []}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Adds a schedule reference to the event.
|
||||
"""
|
||||
def add_schedule(%__MODULE__{} = event, schedule_ref) do
|
||||
%{event | schedules: [schedule_ref | event.schedules]}
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Private Functions
|
||||
# ============================================================================
|
||||
|
||||
defp start_scheduled_event(_channel_id) do
|
||||
# Find the scheduled event and start it
|
||||
Logger.info("Auto-starting scheduled event")
|
||||
:ok
|
||||
end
|
||||
|
||||
defp event_module(:coconut), do: Odinsea.Game.Events.Coconut
|
||||
defp event_module(:fitness), do: Odinsea.Game.Events.Fitness
|
||||
defp event_module(:ola_ola), do: Odinsea.Game.Events.OlaOla
|
||||
defp event_module(:ox_quiz), do: Odinsea.Game.Events.OxQuiz
|
||||
defp event_module(:snowball), do: Odinsea.Game.Events.Snowball
|
||||
defp event_module(:survival), do: Odinsea.Game.Events.Survival
|
||||
defp event_module(_), do: nil
|
||||
|
||||
defp humanize_event_name(type) do
|
||||
type
|
||||
|> Atom.to_string()
|
||||
|> String.replace("_", " ")
|
||||
|> String.split()
|
||||
|> Enum.map(&String.capitalize/1)
|
||||
|> Enum.join(" ")
|
||||
end
|
||||
end
|
||||
606
lib/odinsea/game/event_manager.ex
Normal file
606
lib/odinsea/game/event_manager.ex
Normal file
@@ -0,0 +1,606 @@
|
||||
defmodule Odinsea.Game.EventManager do
|
||||
@moduledoc """
|
||||
Event Manager for scheduling and managing in-game events.
|
||||
Ported from Java `server.events` scheduling functionality.
|
||||
|
||||
## Responsibilities
|
||||
- Event scheduling per channel
|
||||
- Player registration for events
|
||||
- Event state management
|
||||
- Event coordination across channels
|
||||
|
||||
## Event Types
|
||||
- Coconut - Team coconut hitting
|
||||
- Fitness - Obstacle course
|
||||
- OlaOla - Portal guessing
|
||||
- OxQuiz - True/False quiz
|
||||
- Snowball - Team snowball rolling
|
||||
- Survival - Last man standing
|
||||
|
||||
## Usage
|
||||
Event scheduling is typically done by GM commands or automated system:
|
||||
|
||||
# Schedule an event
|
||||
EventManager.schedule_event(channel_id, :coconut)
|
||||
|
||||
# Player joins event
|
||||
EventManager.join_event(channel_id, character_id, :coconut)
|
||||
|
||||
# Start the event
|
||||
EventManager.start_event(channel_id, :coconut)
|
||||
"""
|
||||
|
||||
use GenServer
|
||||
|
||||
alias Odinsea.Game.Events
|
||||
alias Odinsea.Game.Event
|
||||
alias Odinsea.Game.Timer.EventTimer
|
||||
|
||||
require Logger
|
||||
|
||||
# ============================================================================
|
||||
# Types
|
||||
# ============================================================================
|
||||
|
||||
@typedoc "Channel event state"
|
||||
@type channel_events :: %{
|
||||
optional(Events.t()) => Event.t() | struct()
|
||||
}
|
||||
|
||||
@typedoc "Manager state"
|
||||
@type state :: %{
|
||||
channels: %{optional(non_neg_integer()) => channel_events()},
|
||||
schedules: %{optional(reference()) => {:auto_start, non_neg_integer(), Events.t()}}
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Client API
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Starts the Event Manager.
|
||||
"""
|
||||
def start_link(_opts) do
|
||||
GenServer.start_link(__MODULE__, [], name: __MODULE__)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Schedules an event to run on a specific channel.
|
||||
|
||||
## Parameters
|
||||
- channel_id: Channel to run event on
|
||||
- event_type: Type of event (:coconut, :fitness, etc.)
|
||||
|
||||
## Returns
|
||||
- :ok on success
|
||||
- {:error, reason} on failure
|
||||
"""
|
||||
def schedule_event(channel_id, event_type) do
|
||||
GenServer.call(__MODULE__, {:schedule_event, channel_id, event_type})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Starts a scheduled event immediately.
|
||||
|
||||
## Parameters
|
||||
- channel_id: Channel running the event
|
||||
- event_type: Type of event
|
||||
"""
|
||||
def start_event(channel_id, event_type) do
|
||||
GenServer.call(__MODULE__, {:start_event, channel_id, event_type})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Cancels a scheduled event.
|
||||
|
||||
## Parameters
|
||||
- channel_id: Channel with the event
|
||||
- event_type: Type of event
|
||||
"""
|
||||
def cancel_event(channel_id, event_type) do
|
||||
GenServer.call(__MODULE__, {:cancel_event, channel_id, event_type})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Registers a player for an event.
|
||||
|
||||
## Parameters
|
||||
- channel_id: Channel with the event
|
||||
- character_id: Character joining
|
||||
- event_type: Type of event
|
||||
"""
|
||||
def join_event(channel_id, character_id, event_type) do
|
||||
GenServer.call(__MODULE__, {:join_event, channel_id, character_id, event_type})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Unregisters a player from an event.
|
||||
|
||||
## Parameters
|
||||
- channel_id: Channel with the event
|
||||
- character_id: Character leaving
|
||||
- event_type: Type of event
|
||||
"""
|
||||
def leave_event(channel_id, character_id, event_type) do
|
||||
GenServer.call(__MODULE__, {:leave_event, channel_id, character_id, event_type})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Handles a player loading into an event map.
|
||||
Called by map load handlers.
|
||||
|
||||
## Parameters
|
||||
- channel_id: Channel the player is on
|
||||
- character: Character struct
|
||||
- map_id: Map ID player loaded into
|
||||
"""
|
||||
def on_map_load(channel_id, character, map_id) do
|
||||
GenServer.cast(__MODULE__, {:on_map_load, channel_id, character, map_id})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Handles a GM manually starting an event.
|
||||
"""
|
||||
def on_start_event(channel_id, character, map_id) do
|
||||
GenServer.cast(__MODULE__, {:on_start_event, channel_id, character, map_id})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the active event on a channel.
|
||||
"""
|
||||
def get_active_event(channel_id) do
|
||||
GenServer.call(__MODULE__, {:get_active_event, channel_id})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets all events for a channel.
|
||||
"""
|
||||
def get_channel_events(channel_id) do
|
||||
GenServer.call(__MODULE__, {:get_channel_events, channel_id})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if an event is running on a channel.
|
||||
"""
|
||||
def event_running?(channel_id, event_type) do
|
||||
GenServer.call(__MODULE__, {:event_running?, channel_id, event_type})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Sets the active event map for a channel.
|
||||
This is the map where players should go to join.
|
||||
"""
|
||||
def set_event_map(channel_id, map_id) do
|
||||
GenServer.cast(__MODULE__, {:set_event_map, channel_id, map_id})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the event map for a channel (where players join).
|
||||
"""
|
||||
def get_event_map(channel_id) do
|
||||
GenServer.call(__MODULE__, {:get_event_map, channel_id})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Lists all available event types.
|
||||
"""
|
||||
def list_event_types do
|
||||
Events.all()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets event info for a type.
|
||||
"""
|
||||
def event_info(event_type) do
|
||||
%{
|
||||
type: event_type,
|
||||
name: Events.display_name(event_type),
|
||||
map_ids: Events.map_ids(event_type),
|
||||
entry_map: Events.entry_map_id(event_type),
|
||||
stages: Events.stage_count(event_type),
|
||||
is_race: Events.race_event?(event_type),
|
||||
is_team: Events.team_event?(event_type)
|
||||
}
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Server Callbacks
|
||||
# ============================================================================
|
||||
|
||||
@impl true
|
||||
def init(_) do
|
||||
Logger.info("EventManager started")
|
||||
|
||||
state = %{
|
||||
channels: %{},
|
||||
schedules: %{},
|
||||
event_maps: %{} # channel_id => map_id
|
||||
}
|
||||
|
||||
{:ok, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:schedule_event, channel_id, event_type}, _from, state) do
|
||||
case do_schedule_event(state, channel_id, event_type) do
|
||||
{:ok, new_state} ->
|
||||
broadcast_event_notice(channel_id, event_type)
|
||||
{:reply, :ok, new_state}
|
||||
|
||||
{:error, reason} ->
|
||||
{:reply, {:error, reason}, state}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:start_event, channel_id, event_type}, _from, state) do
|
||||
case do_start_event(state, channel_id, event_type) do
|
||||
{:ok, new_state} ->
|
||||
{:reply, :ok, new_state}
|
||||
|
||||
{:error, reason} ->
|
||||
{:reply, {:error, reason}, state}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:cancel_event, channel_id, event_type}, _from, state) do
|
||||
new_state = do_cancel_event(state, channel_id, event_type)
|
||||
{:reply, :ok, new_state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:join_event, channel_id, character_id, event_type}, _from, state) do
|
||||
{reply, new_state} = do_join_event(state, channel_id, character_id, event_type)
|
||||
{:reply, reply, new_state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:leave_event, channel_id, character_id, event_type}, _from, state) do
|
||||
{reply, new_state} = do_leave_event(state, channel_id, character_id, event_type)
|
||||
{:reply, reply, new_state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:get_active_event, channel_id}, _from, state) do
|
||||
event = get_active_event_impl(state, channel_id)
|
||||
{:reply, event, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:get_channel_events, channel_id}, _from, state) do
|
||||
events = Map.get(state.channels, channel_id, %{})
|
||||
{:reply, events, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:event_running?, channel_id, event_type}, _from, state) do
|
||||
running = event_running_impl?(state, channel_id, event_type)
|
||||
{:reply, running, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:get_event_map, channel_id}, _from, state) do
|
||||
map_id = Map.get(state.event_maps, channel_id)
|
||||
{:reply, map_id, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_cast({:on_map_load, channel_id, character, map_id}, state) do
|
||||
new_state = do_on_map_load(state, channel_id, character, map_id)
|
||||
{:noreply, new_state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_cast({:on_start_event, channel_id, character, map_id}, state) do
|
||||
new_state = do_on_start_event(state, channel_id, character, map_id)
|
||||
{:noreply, new_state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_cast({:set_event_map, channel_id, map_id}, state) do
|
||||
new_maps = Map.put(state.event_maps, channel_id, map_id)
|
||||
{:noreply, %{state | event_maps: new_maps}}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:auto_start, channel_id, event_type}, state) do
|
||||
Logger.info("Auto-starting event #{event_type} on channel #{channel_id}")
|
||||
|
||||
# Start the event
|
||||
case do_start_event(state, channel_id, event_type) do
|
||||
{:ok, new_state} ->
|
||||
# Clear event map
|
||||
new_maps = Map.delete(new_state.event_maps, channel_id)
|
||||
{:noreply, %{new_state | event_maps: new_maps}}
|
||||
|
||||
{:error, _} ->
|
||||
{:noreply, state}
|
||||
end
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Private Functions
|
||||
# ============================================================================
|
||||
|
||||
defp do_schedule_event(state, channel_id, event_type) do
|
||||
# Check if event type is valid
|
||||
if event_type not in Events.all() do
|
||||
{:error, "Invalid event type"}
|
||||
else
|
||||
# Check if event is already running
|
||||
if event_running_impl?(state, channel_id, event_type) do
|
||||
{:error, "Event already running"}
|
||||
else
|
||||
# Create event instance
|
||||
event = create_event(event_type, channel_id)
|
||||
|
||||
# Reset event
|
||||
event = reset_event(event, event_type)
|
||||
|
||||
# Store event
|
||||
channel_events = Map.get(state.channels, channel_id, %{})
|
||||
channel_events = Map.put(channel_events, event_type, event)
|
||||
channels = Map.put(state.channels, channel_id, channel_events)
|
||||
|
||||
# Set event map (entry map)
|
||||
entry_map = Events.entry_map_id(event_type)
|
||||
event_maps = Map.put(state.event_maps, channel_id, entry_map)
|
||||
|
||||
{:ok, %{state | channels: channels, event_maps: event_maps}}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp do_start_event(state, channel_id, event_type) do
|
||||
with {:ok, event} <- get_event(state, channel_id, event_type) do
|
||||
# Start the event
|
||||
new_event = start_event_impl(event, event_type)
|
||||
|
||||
# Update state
|
||||
channel_events = Map.get(state.channels, channel_id, %{})
|
||||
channel_events = Map.put(channel_events, event_type, new_event)
|
||||
channels = Map.put(state.channels, channel_id, channel_events)
|
||||
|
||||
# Clear event map
|
||||
event_maps = Map.delete(state.event_maps, channel_id)
|
||||
|
||||
{:ok, %{state | channels: channels, event_maps: event_maps}}
|
||||
else
|
||||
nil -> {:error, "Event not found"}
|
||||
end
|
||||
end
|
||||
|
||||
defp do_cancel_event(state, channel_id, event_type) do
|
||||
with {:ok, event} <- get_event(state, channel_id, event_type) do
|
||||
# Unreset event (cleanup)
|
||||
unreset_event(event, event_type)
|
||||
|
||||
# Remove from state
|
||||
channel_events = Map.get(state.channels, channel_id, %{})
|
||||
channel_events = Map.delete(channel_events, event_type)
|
||||
channels = Map.put(state.channels, channel_id, channel_events)
|
||||
|
||||
# Clear event map
|
||||
event_maps = Map.delete(state.event_maps, channel_id)
|
||||
|
||||
%{state | channels: channels, event_maps: event_maps}
|
||||
else
|
||||
nil -> state
|
||||
end
|
||||
end
|
||||
|
||||
defp do_join_event(state, channel_id, character_id, event_type) do
|
||||
with {:ok, event} <- get_event(state, channel_id, event_type) do
|
||||
if event.base.is_running do
|
||||
# Register player
|
||||
new_event = Event.register_player(event.base, character_id)
|
||||
new_event = %{event | base: new_event}
|
||||
|
||||
# Check if we should auto-start (250 players)
|
||||
new_event = Event.increment_player_count(new_event.base)
|
||||
new_event = %{event | base: new_event}
|
||||
|
||||
# Update state
|
||||
channel_events = Map.get(state.channels, channel_id, %{})
|
||||
channel_events = Map.put(channel_events, event_type, new_event)
|
||||
channels = Map.put(state.channels, channel_id, channel_events)
|
||||
|
||||
{{:ok, :joined}, %{state | channels: channels}}
|
||||
else
|
||||
{{:error, "Event not running"}, state}
|
||||
end
|
||||
else
|
||||
nil -> {{:error, "Event not found"}, state}
|
||||
end
|
||||
end
|
||||
|
||||
defp do_leave_event(state, channel_id, character_id, event_type) do
|
||||
with {:ok, event} <- get_event(state, channel_id, event_type) do
|
||||
# Unregister player
|
||||
new_base = Event.unregister_player(event.base, character_id)
|
||||
new_event = %{event | base: new_base}
|
||||
|
||||
# Update state
|
||||
channel_events = Map.get(state.channels, channel_id, %{})
|
||||
channel_events = Map.put(channel_events, event_type, new_event)
|
||||
channels = Map.put(state.channels, channel_id, channel_events)
|
||||
|
||||
{{:ok, :left}, %{state | channels: channels}}
|
||||
else
|
||||
nil -> {{:error, "Event not found"}, state}
|
||||
end
|
||||
end
|
||||
|
||||
defp do_on_map_load(state, channel_id, character, map_id) do
|
||||
# Check if any event is running on this channel
|
||||
channel_events = Map.get(state.channels, channel_id, %{})
|
||||
|
||||
Enum.reduce(channel_events, state, fn {event_type, event}, acc_state ->
|
||||
if event.base.is_running do
|
||||
# Check if this is the finish map
|
||||
if map_id == 109050000 do
|
||||
# Call finished callback
|
||||
finished_event(event, event_type, character)
|
||||
end
|
||||
|
||||
# Check if this is an event map
|
||||
if Event.event_map?(event.base, map_id) do
|
||||
# Call on_map_load callback
|
||||
on_map_load_event(event, event_type, character)
|
||||
|
||||
# If first map, increment player count
|
||||
if Event.map_index(event.base, map_id) == 0 do
|
||||
new_base = Event.increment_player_count(event.base)
|
||||
|
||||
# Check if we hit 250 players
|
||||
if new_base.player_count >= 250 do
|
||||
# Auto-start
|
||||
schedule_auto_start(channel_id, event_type)
|
||||
end
|
||||
|
||||
# Update event in state
|
||||
new_event = put_event_base(event, event_type, new_base)
|
||||
channel_events = Map.put(acc_state.channels[channel_id], event_type, new_event)
|
||||
channels = Map.put(acc_state.channels, channel_id, channel_events)
|
||||
%{acc_state | channels: channels}
|
||||
else
|
||||
acc_state
|
||||
end
|
||||
else
|
||||
acc_state
|
||||
end
|
||||
else
|
||||
acc_state
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp do_on_start_event(state, channel_id, character, map_id) do
|
||||
channel_events = Map.get(state.channels, channel_id, %{})
|
||||
|
||||
Enum.find_value(channel_events, state, fn {event_type, event} ->
|
||||
if event.base.is_running and Event.event_map?(event.base, map_id) do
|
||||
# Start the event
|
||||
new_event = start_event_impl(event, event_type)
|
||||
|
||||
# Update state
|
||||
channel_events = Map.put(channel_events, event_type, new_event)
|
||||
channels = Map.put(state.channels, channel_id, channel_events)
|
||||
|
||||
# Clear event map
|
||||
event_maps = Map.delete(state.event_maps, channel_id)
|
||||
|
||||
%{state | channels: channels, event_maps: event_maps}
|
||||
end
|
||||
end) || state
|
||||
end
|
||||
|
||||
defp get_event(state, channel_id, event_type) do
|
||||
channel_events = Map.get(state.channels, channel_id, %{})
|
||||
|
||||
case Map.get(channel_events, event_type) do
|
||||
nil -> nil
|
||||
event -> {:ok, event}
|
||||
end
|
||||
end
|
||||
|
||||
defp get_active_event_impl(state, channel_id) do
|
||||
channel_events = Map.get(state.channels, channel_id, %{})
|
||||
|
||||
Enum.find_value(channel_events, fn {event_type, event} ->
|
||||
if event.base.is_running do
|
||||
{event_type, event}
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp event_running_impl?(state, channel_id, event_type) do
|
||||
case get_event(state, channel_id, event_type) do
|
||||
{:ok, event} -> event.base.is_running
|
||||
nil -> false
|
||||
end
|
||||
end
|
||||
|
||||
defp create_event(:coconut, channel_id), do: Odinsea.Game.Events.Coconut.new(channel_id)
|
||||
defp create_event(:fitness, channel_id), do: Odinsea.Game.Events.Fitness.new(channel_id)
|
||||
defp create_event(:ola_ola, channel_id), do: Odinsea.Game.Events.OlaOla.new(channel_id)
|
||||
defp create_event(:ox_quiz, channel_id), do: Odinsea.Game.Events.OxQuiz.new(channel_id)
|
||||
defp create_event(:snowball, channel_id), do: Odinsea.Game.Events.Snowball.new(channel_id)
|
||||
defp create_event(:survival, channel_id), do: Odinsea.Game.Events.Survival.new(channel_id)
|
||||
defp create_event(_, _), do: nil
|
||||
|
||||
defp reset_event(event, :coconut), do: Odinsea.Game.Events.Coconut.reset(event)
|
||||
defp reset_event(event, :fitness), do: Odinsea.Game.Events.Fitness.reset(event)
|
||||
defp reset_event(event, :ola_ola), do: Odinsea.Game.Events.OlaOla.reset(event)
|
||||
defp reset_event(event, :ox_quiz), do: Odinsea.Game.Events.OxQuiz.reset(event)
|
||||
defp reset_event(event, :snowball), do: Odinsea.Game.Events.Snowball.reset(event)
|
||||
defp reset_event(event, :survival), do: Odinsea.Game.Events.Survival.reset(event)
|
||||
defp reset_event(event, _), do: event
|
||||
|
||||
defp unreset_event(event, :coconut), do: Odinsea.Game.Events.Coconut.unreset(event)
|
||||
defp unreset_event(event, :fitness), do: Odinsea.Game.Events.Fitness.unreset(event)
|
||||
defp unreset_event(event, :ola_ola), do: Odinsea.Game.Events.OlaOla.unreset(event)
|
||||
defp unreset_event(event, :ox_quiz), do: Odinsea.Game.Events.OxQuiz.unreset(event)
|
||||
defp unreset_event(event, :snowball), do: Odinsea.Game.Events.Snowball.unreset(event)
|
||||
defp unreset_event(event, :survival), do: Odinsea.Game.Events.Survival.unreset(event)
|
||||
defp unreset_event(event, _), do: event
|
||||
|
||||
defp start_event_impl(event, :coconut), do: Odinsea.Game.Events.Coconut.start_event(event)
|
||||
defp start_event_impl(event, :fitness), do: Odinsea.Game.Events.Fitness.start_event(event)
|
||||
defp start_event_impl(event, :ola_ola), do: Odinsea.Game.Events.OlaOla.start_event(event)
|
||||
defp start_event_impl(event, :ox_quiz), do: Odinsea.Game.Events.OxQuiz.start_event(event)
|
||||
defp start_event_impl(event, :snowball), do: Odinsea.Game.Events.Snowball.start_event(event)
|
||||
defp start_event_impl(event, :survival), do: Odinsea.Game.Events.Survival.start_event(event)
|
||||
defp start_event_impl(event, _), do: event
|
||||
|
||||
defp finished_event(event, :coconut, character), do: Odinsea.Game.Events.Coconut.finished(event, character)
|
||||
defp finished_event(event, :fitness, character), do: Odinsea.Game.Events.Fitness.finished(event, character)
|
||||
defp finished_event(event, :ola_ola, character), do: Odinsea.Game.Events.OlaOla.finished(event, character)
|
||||
defp finished_event(event, :ox_quiz, character), do: Odinsea.Game.Events.OxQuiz.finished(event, character)
|
||||
defp finished_event(event, :snowball, character), do: Odinsea.Game.Events.Snowball.finished(event, character)
|
||||
defp finished_event(event, :survival, character), do: Odinsea.Game.Events.Survival.finished(event, character)
|
||||
defp finished_event(_, _, _), do: :ok
|
||||
|
||||
defp on_map_load_event(event, :coconut, character), do: Odinsea.Game.Events.Coconut.on_map_load(event, character)
|
||||
defp on_map_load_event(event, :fitness, character), do: Odinsea.Game.Events.Fitness.on_map_load(event, character)
|
||||
defp on_map_load_event(event, :ola_ola, character), do: Odinsea.Game.Events.OlaOla.on_map_load(event, character)
|
||||
defp on_map_load_event(event, :ox_quiz, character), do: Odinsea.Game.Events.OxQuiz.on_map_load(event, character)
|
||||
defp on_map_load_event(event, :snowball, character), do: Odinsea.Game.Events.Snowball.on_map_load(event, character)
|
||||
defp on_map_load_event(event, :survival, character), do: Odinsea.Game.Events.Survival.on_map_load(event, character)
|
||||
defp on_map_load_event(_, _, _), do: :ok
|
||||
|
||||
defp put_event_base(event, :coconut, base), do: %{event | base: base}
|
||||
defp put_event_base(event, :fitness, base), do: %{event | base: base}
|
||||
defp put_event_base(event, :ola_ola, base), do: %{event | base: base}
|
||||
defp put_event_base(event, :ox_quiz, base), do: %{event | base: base}
|
||||
defp put_event_base(event, :snowball, base), do: %{event | base: base}
|
||||
defp put_event_base(event, :survival, base), do: %{event | base: base}
|
||||
defp put_event_base(event, _, _), do: event
|
||||
|
||||
defp schedule_auto_start(channel_id, event_type) do
|
||||
EventTimer.schedule(
|
||||
fn ->
|
||||
send(__MODULE__, {:auto_start, channel_id, event_type})
|
||||
end,
|
||||
30_000 # 30 seconds
|
||||
)
|
||||
|
||||
broadcast_server_notice(channel_id, "The event will start in 30 seconds!")
|
||||
end
|
||||
|
||||
defp broadcast_event_notice(channel_id, event_type) do
|
||||
event_name = Events.display_name(event_type)
|
||||
|
||||
broadcast_server_notice(
|
||||
channel_id,
|
||||
"Hello! Let's play a #{event_name} event in channel #{channel_id}! " <>
|
||||
"Change to channel #{channel_id} and use @event command!"
|
||||
)
|
||||
end
|
||||
|
||||
defp broadcast_server_notice(channel_id, message) do
|
||||
# In real implementation, broadcast to channel
|
||||
Logger.info("[Channel #{channel_id}] #{message}")
|
||||
end
|
||||
end
|
||||
178
lib/odinsea/game/events.ex
Normal file
178
lib/odinsea/game/events.ex
Normal file
@@ -0,0 +1,178 @@
|
||||
defmodule Odinsea.Game.Events do
|
||||
@moduledoc """
|
||||
Event type definitions and map IDs.
|
||||
Ported from Java `server.events.MapleEventType`.
|
||||
|
||||
Each event type has associated map IDs where the event takes place.
|
||||
"""
|
||||
|
||||
@typedoc "Event type atom"
|
||||
@type t :: :coconut | :coke_play | :fitness | :ola_ola | :ox_quiz | :survival | :snowball
|
||||
|
||||
# ============================================================================
|
||||
# Event Map IDs
|
||||
# ============================================================================
|
||||
|
||||
@event_maps %{
|
||||
# Coconut event - team-based coconut hitting
|
||||
coconut: [109080000],
|
||||
|
||||
# Coke Play event (similar to coconut)
|
||||
coke_play: [109080010],
|
||||
|
||||
# Fitness event - 4 stage obstacle course
|
||||
fitness: [109040000, 109040001, 109040002, 109040003, 109040004],
|
||||
|
||||
# Ola Ola event - 3 stage portal guessing game
|
||||
ola_ola: [109030001, 109030002, 109030003],
|
||||
|
||||
# OX Quiz event - True/False quiz
|
||||
ox_quiz: [109020001],
|
||||
|
||||
# Survival event - Last man standing (2 maps)
|
||||
survival: [809040000, 809040100],
|
||||
|
||||
# Snowball event - Team snowball rolling competition
|
||||
snowball: [109060000]
|
||||
}
|
||||
|
||||
@doc """
|
||||
Returns all event types.
|
||||
"""
|
||||
def all do
|
||||
Map.keys(@event_maps)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the map IDs for a given event type.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> Odinsea.Game.Events.map_ids(:coconut)
|
||||
[109080000]
|
||||
|
||||
iex> Odinsea.Game.Events.map_ids(:fitness)
|
||||
[109040000, 109040001, 109040002, 109040003, 109040004]
|
||||
"""
|
||||
def map_ids(event_type) do
|
||||
Map.get(@event_maps, event_type, [])
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the entry map ID (first map) for an event type.
|
||||
"""
|
||||
def entry_map_id(event_type) do
|
||||
case map_ids(event_type) do
|
||||
[first | _] -> first
|
||||
[] -> nil
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets an event type by string name (case-insensitive).
|
||||
|
||||
## Examples
|
||||
|
||||
iex> Odinsea.Game.Events.get_by_string("coconut")
|
||||
:coconut
|
||||
|
||||
iex> Odinsea.Game.Events.get_by_string("OX_QUIZ")
|
||||
:ox_quiz
|
||||
|
||||
iex> Odinsea.Game.Events.get_by_string("invalid")
|
||||
nil
|
||||
"""
|
||||
def get_by_string(str) when is_binary(str) do
|
||||
str = String.downcase(str)
|
||||
|
||||
Enum.find(all(), fn type ->
|
||||
Atom.to_string(type) == str or
|
||||
String.replace(Atom.to_string(type), "_", "") == str
|
||||
end)
|
||||
end
|
||||
|
||||
def get_by_string(_), do: nil
|
||||
|
||||
@doc """
|
||||
Returns a human-readable name for the event type.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> Odinsea.Game.Events.display_name(:coconut)
|
||||
"Coconut"
|
||||
|
||||
iex> Odinsea.Game.Events.display_name(:ola_ola)
|
||||
"Ola Ola"
|
||||
"""
|
||||
def display_name(event_type) do
|
||||
event_type
|
||||
|> Atom.to_string()
|
||||
|> String.replace("_", " ")
|
||||
|> String.split()
|
||||
|> Enum.map(&String.capitalize/1)
|
||||
|> Enum.join(" ")
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the number of stages/maps for an event.
|
||||
"""
|
||||
def stage_count(event_type) do
|
||||
length(map_ids(event_type))
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if a map ID belongs to any event.
|
||||
Returns the event type if found, nil otherwise.
|
||||
"""
|
||||
def event_for_map(map_id) when is_integer(map_id) do
|
||||
Enum.find(all(), fn type ->
|
||||
map_id in map_ids(type)
|
||||
end)
|
||||
end
|
||||
|
||||
def event_for_map(_), do: nil
|
||||
|
||||
@doc """
|
||||
Checks if a map ID is the finish map (109050000).
|
||||
"""
|
||||
def finish_map?(109050000), do: true
|
||||
def finish_map?(_), do: false
|
||||
|
||||
@doc """
|
||||
Returns true if the event is a race-type event (timed).
|
||||
"""
|
||||
def race_event?(:fitness), do: true
|
||||
def race_event?(:ola_ola), do: true
|
||||
def race_event?(:survival), do: true
|
||||
def race_event?(_), do: false
|
||||
|
||||
@doc """
|
||||
Returns true if the event is team-based.
|
||||
"""
|
||||
def team_event?(:coconut), do: true
|
||||
def team_event?(:snowball), do: true
|
||||
def team_event?(_), do: false
|
||||
|
||||
@doc """
|
||||
Returns true if the event has multiple stages.
|
||||
"""
|
||||
def multi_stage?(event_type) do
|
||||
stage_count(event_type) > 1
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the stage index for a map ID within an event.
|
||||
Returns 0-based index or nil if not part of event.
|
||||
"""
|
||||
def stage_index(event_type, map_id) do
|
||||
map_ids(event_type)
|
||||
|> Enum.find_index(&(&1 == map_id))
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns all event data as a map.
|
||||
"""
|
||||
def all_event_data do
|
||||
@event_maps
|
||||
end
|
||||
end
|
||||
393
lib/odinsea/game/events/coconut.ex
Normal file
393
lib/odinsea/game/events/coconut.ex
Normal file
@@ -0,0 +1,393 @@
|
||||
defmodule Odinsea.Game.Events.Coconut do
|
||||
@moduledoc """
|
||||
Coconut Event - Team-based coconut hitting competition.
|
||||
Ported from Java `server.events.MapleCoconut`.
|
||||
|
||||
## Gameplay
|
||||
- Two teams (Maple vs Story) compete to hit coconuts
|
||||
- Coconuts spawn and fall when hit
|
||||
- Team with most hits at end wins
|
||||
- 5 minute time limit with potential 1 minute bonus time
|
||||
|
||||
## Map
|
||||
- Single map: 109080000
|
||||
|
||||
## Win Condition
|
||||
- Team with higher score after 5 minutes wins
|
||||
- If tied, 1 minute bonus time is awarded
|
||||
- If still tied after bonus, no winner
|
||||
"""
|
||||
|
||||
alias Odinsea.Game.Event
|
||||
alias Odinsea.Game.Timer.EventTimer
|
||||
|
||||
require Logger
|
||||
|
||||
# ============================================================================
|
||||
# Types
|
||||
# ============================================================================
|
||||
|
||||
@typedoc "Coconut struct representing a single coconut"
|
||||
@type coconut :: %{
|
||||
id: non_neg_integer(),
|
||||
hits: non_neg_integer(),
|
||||
hittable: boolean(),
|
||||
stopped: boolean(),
|
||||
hit_time: integer() # Unix timestamp ms
|
||||
}
|
||||
|
||||
@typedoc "Coconut event state"
|
||||
@type t :: %__MODULE__{
|
||||
base: Event.t(),
|
||||
coconuts: [coconut()],
|
||||
maple_score: non_neg_integer(), # Team 0
|
||||
story_score: non_neg_integer(), # Team 1
|
||||
count_bombing: non_neg_integer(),
|
||||
count_falling: non_neg_integer(),
|
||||
count_stopped: non_neg_integer(),
|
||||
schedules: [reference()]
|
||||
}
|
||||
|
||||
defstruct [
|
||||
:base,
|
||||
coconuts: [],
|
||||
maple_score: 0,
|
||||
story_score: 0,
|
||||
count_bombing: 80,
|
||||
count_falling: 401,
|
||||
count_stopped: 20,
|
||||
schedules: []
|
||||
]
|
||||
|
||||
# ============================================================================
|
||||
# Constants
|
||||
# ============================================================================
|
||||
|
||||
@map_ids [109080000]
|
||||
@event_duration 300_000 # 5 minutes in ms
|
||||
@bonus_duration 60_000 # 1 minute bonus time
|
||||
@total_coconuts 506
|
||||
@warp_out_delay 10_000 # 10 seconds after game end
|
||||
|
||||
# ============================================================================
|
||||
# Event Implementation
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Creates a new Coconut event for the given channel.
|
||||
"""
|
||||
def new(channel_id) do
|
||||
base = Event.new(:coconut, channel_id, @map_ids)
|
||||
|
||||
%__MODULE__{
|
||||
base: base,
|
||||
coconuts: initialize_coconuts(),
|
||||
maple_score: 0,
|
||||
story_score: 0,
|
||||
count_bombing: 80,
|
||||
count_falling: 401,
|
||||
count_stopped: 20,
|
||||
schedules: []
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the map IDs for this event type.
|
||||
"""
|
||||
def map_ids, do: @map_ids
|
||||
|
||||
@doc """
|
||||
Resets the event state for a new game.
|
||||
"""
|
||||
def reset(%__MODULE__{} = event) do
|
||||
base = %{event.base | is_running: true, player_count: 0}
|
||||
|
||||
%__MODULE__{
|
||||
event |
|
||||
base: base,
|
||||
coconuts: initialize_coconuts(),
|
||||
maple_score: 0,
|
||||
story_score: 0,
|
||||
count_bombing: 80,
|
||||
count_falling: 401,
|
||||
count_stopped: 20,
|
||||
schedules: []
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Cleans up the event after it ends.
|
||||
"""
|
||||
def unreset(%__MODULE__{} = event) do
|
||||
# Cancel all schedules
|
||||
Event.cancel_schedules(event.base)
|
||||
|
||||
base = %{event.base | is_running: false, player_count: 0}
|
||||
|
||||
%__MODULE__{
|
||||
event |
|
||||
base: base,
|
||||
coconuts: [],
|
||||
maple_score: 0,
|
||||
story_score: 0,
|
||||
schedules: []
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Called when a player finishes (reaches end map).
|
||||
Coconut event doesn't use this - winners determined by time.
|
||||
"""
|
||||
def finished(_event, _character) do
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Called when a player loads into the event map.
|
||||
Sends coconut score packet.
|
||||
"""
|
||||
def on_map_load(%__MODULE__{} = event, character) do
|
||||
# Send coconut score packet
|
||||
Logger.debug("Sending coconut score to #{character.name}: Maple #{event.maple_score}, Story #{event.story_score}")
|
||||
|
||||
# In real implementation: send packet with scores
|
||||
# Packet format: coconutScore(maple_score, story_score)
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Starts the coconut event gameplay.
|
||||
"""
|
||||
def start_event(%__MODULE__{} = event) do
|
||||
Logger.info("Starting Coconut event on channel #{event.base.channel_id}")
|
||||
|
||||
# Set coconuts hittable
|
||||
event = set_hittable(event, true)
|
||||
|
||||
# Broadcast event start
|
||||
Event.broadcast_to_event(event.base, :event_started)
|
||||
Event.broadcast_to_event(event.base, :hit_coconut)
|
||||
|
||||
# Start 5-minute countdown
|
||||
Event.broadcast_to_event(event.base, {:clock, div(@event_duration, 1000)})
|
||||
|
||||
# Schedule end check
|
||||
schedule_ref = EventTimer.schedule(
|
||||
fn -> check_winner(event) end,
|
||||
@event_duration
|
||||
)
|
||||
|
||||
%{event | schedules: [schedule_ref]}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a coconut by ID.
|
||||
Returns nil if ID is out of range.
|
||||
"""
|
||||
def get_coconut(%__MODULE__{coconuts: coconuts}, id) when id >= 0 and id < length(coconuts) do
|
||||
Enum.at(coconuts, id)
|
||||
end
|
||||
|
||||
def get_coconut(_, _), do: nil
|
||||
|
||||
@doc """
|
||||
Returns all coconuts.
|
||||
"""
|
||||
def get_all_coconuts(%__MODULE__{coconuts: coconuts}), do: coconuts
|
||||
|
||||
@doc """
|
||||
Sets whether coconuts are hittable.
|
||||
"""
|
||||
def set_hittable(%__MODULE__{coconuts: coconuts} = event, hittable) do
|
||||
updated_coconuts = Enum.map(coconuts, fn coconut ->
|
||||
%{coconut | hittable: hittable}
|
||||
end)
|
||||
|
||||
%{event | coconuts: updated_coconuts}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the number of available bombings.
|
||||
"""
|
||||
def get_bombings(%__MODULE__{count_bombing: count}), do: count
|
||||
|
||||
@doc """
|
||||
Decrements bombing count.
|
||||
"""
|
||||
def bomb_coconut(%__MODULE__{count_bombing: count} = event) do
|
||||
%{event | count_bombing: max(0, count - 1)}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the number of falling coconuts available.
|
||||
"""
|
||||
def get_falling(%__MODULE__{count_falling: count}), do: count
|
||||
|
||||
@doc """
|
||||
Decrements falling count.
|
||||
"""
|
||||
def fall_coconut(%__MODULE__{count_falling: count} = event) do
|
||||
%{event | count_falling: max(0, count - 1)}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the number of stopped coconuts.
|
||||
"""
|
||||
def get_stopped(%__MODULE__{count_stopped: count}), do: count
|
||||
|
||||
@doc """
|
||||
Decrements stopped count.
|
||||
"""
|
||||
def stop_coconut(%__MODULE__{count_stopped: count} = event) do
|
||||
%{event | count_stopped: max(0, count - 1)}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the current scores [maple, story].
|
||||
"""
|
||||
def get_coconut_score(%__MODULE__{} = event) do
|
||||
[event.maple_score, event.story_score]
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets Team Maple score.
|
||||
"""
|
||||
def get_maple_score(%__MODULE__{maple_score: score}), do: score
|
||||
|
||||
@doc """
|
||||
Gets Team Story score.
|
||||
"""
|
||||
def get_story_score(%__MODULE__{story_score: score}), do: score
|
||||
|
||||
@doc """
|
||||
Adds a point to Team Maple.
|
||||
"""
|
||||
def add_maple_score(%__MODULE__{maple_score: score} = event) do
|
||||
%{event | maple_score: score + 1}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Adds a point to Team Story.
|
||||
"""
|
||||
def add_story_score(%__MODULE__{story_score: score} = event) do
|
||||
%{event | story_score: score + 1}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Records a hit on a coconut.
|
||||
"""
|
||||
def hit_coconut(%__MODULE__{coconuts: coconuts} = event, coconut_id, team) do
|
||||
now = System.system_time(:millisecond)
|
||||
|
||||
updated_coconuts = List.update_at(coconuts, coconut_id, fn coconut ->
|
||||
%{coconut |
|
||||
hits: coconut.hits + 1,
|
||||
hit_time: now + 1000 # 1 second cooldown
|
||||
}
|
||||
end)
|
||||
|
||||
# Add score to appropriate team
|
||||
event = %{event | coconuts: updated_coconuts}
|
||||
|
||||
event = case team do
|
||||
0 -> add_maple_score(event)
|
||||
1 -> add_story_score(event)
|
||||
_ -> event
|
||||
end
|
||||
|
||||
event
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Private Functions
|
||||
# ============================================================================
|
||||
|
||||
defp initialize_coconuts do
|
||||
Enum.map(0..(@total_coconuts - 1), fn id ->
|
||||
%{
|
||||
id: id,
|
||||
hits: 0,
|
||||
hittable: false,
|
||||
stopped: false,
|
||||
hit_time: 0
|
||||
}
|
||||
end)
|
||||
end
|
||||
|
||||
defp check_winner(%__MODULE__{} = event) do
|
||||
if get_maple_score(event) == get_story_score(event) do
|
||||
# Tie - bonus time
|
||||
bonus_time(event)
|
||||
else
|
||||
# We have a winner
|
||||
winner_team = if get_maple_score(event) > get_story_score(event), do: 0, else: 1
|
||||
end_game(event, winner_team)
|
||||
end
|
||||
end
|
||||
|
||||
defp bonus_time(%__MODULE__{} = event) do
|
||||
Logger.info("Coconut event tied! Starting bonus time...")
|
||||
|
||||
# Broadcast bonus time
|
||||
Event.broadcast_to_event(event.base, {:clock, div(@bonus_duration, 1000)})
|
||||
|
||||
# Schedule final check
|
||||
EventTimer.schedule(
|
||||
fn ->
|
||||
if get_maple_score(event) == get_story_score(event) do
|
||||
# Still tied - no winner
|
||||
end_game_no_winner(event)
|
||||
else
|
||||
winner_team = if get_maple_score(event) > get_story_score(event), do: 0, else: 1
|
||||
end_game(event, winner_team)
|
||||
end
|
||||
end,
|
||||
@bonus_duration
|
||||
)
|
||||
end
|
||||
|
||||
defp end_game(%__MODULE__{} = event, winner_team) do
|
||||
team_name = if winner_team == 0, do: "Maple", else: "Story"
|
||||
Logger.info("Coconut event ended! Team #{team_name} wins!")
|
||||
|
||||
# Broadcast winner
|
||||
Event.broadcast_to_event(event.base, {:victory, winner_team})
|
||||
|
||||
# Schedule warp out
|
||||
EventTimer.schedule(
|
||||
fn -> warp_out(event, winner_team) end,
|
||||
@warp_out_delay
|
||||
)
|
||||
end
|
||||
|
||||
defp end_game_no_winner(%__MODULE__{} = event) do
|
||||
Logger.info("Coconut event ended with no winner (tie)")
|
||||
|
||||
# Broadcast no winner
|
||||
Event.broadcast_to_event(event.base, :no_winner)
|
||||
|
||||
# Schedule warp out
|
||||
EventTimer.schedule(
|
||||
fn -> warp_out(event, nil) end,
|
||||
@warp_out_delay
|
||||
)
|
||||
end
|
||||
|
||||
defp warp_out(%__MODULE__{} = event, winner_team) do
|
||||
# Make coconuts unhittable
|
||||
event = set_hittable(event, false)
|
||||
|
||||
# Give prizes to winners, warp everyone back
|
||||
# In real implementation:
|
||||
# - Get all characters on map
|
||||
# - For each character:
|
||||
# - If on winning team, give prize
|
||||
# - Warp back to saved location
|
||||
|
||||
Logger.info("Warping out all players from coconut event")
|
||||
|
||||
# Unreset event
|
||||
unreset(event)
|
||||
end
|
||||
end
|
||||
298
lib/odinsea/game/events/fitness.ex
Normal file
298
lib/odinsea/game/events/fitness.ex
Normal file
@@ -0,0 +1,298 @@
|
||||
defmodule Odinsea.Game.Events.Fitness do
|
||||
@moduledoc """
|
||||
Fitness Event - Maple Physical Fitness Test obstacle course.
|
||||
Ported from Java `server.events.MapleFitness`.
|
||||
|
||||
## Gameplay
|
||||
- 4 stage obstacle course that players must navigate
|
||||
- Time limit of 10 minutes
|
||||
- Players who reach the end within time limit get prize
|
||||
- Death during event results in elimination
|
||||
|
||||
## Maps
|
||||
- Stage 1: 109040000 (Start - monkeys throwing bananas)
|
||||
- Stage 2: 109040001 (Stage 2 - monkeys)
|
||||
- Stage 3: 109040002 (Stage 3 - traps)
|
||||
- Stage 4: 109040003 (Stage 4 - last stage)
|
||||
- Finish: 109040004
|
||||
|
||||
## Win Condition
|
||||
- Reach the finish map within 10 minutes
|
||||
- All finishers get prize regardless of order
|
||||
"""
|
||||
|
||||
alias Odinsea.Game.Event
|
||||
alias Odinsea.Game.Timer.EventTimer
|
||||
alias Odinsea.Game.Character
|
||||
|
||||
require Logger
|
||||
|
||||
# ============================================================================
|
||||
# Types
|
||||
# ============================================================================
|
||||
|
||||
@typedoc "Fitness event state"
|
||||
@type t :: %__MODULE__{
|
||||
base: Event.t(),
|
||||
time_started: integer() | nil, # Unix timestamp ms
|
||||
event_duration: non_neg_integer(),
|
||||
schedules: [reference()]
|
||||
}
|
||||
|
||||
defstruct [
|
||||
:base,
|
||||
time_started: nil,
|
||||
event_duration: 600_000, # 10 minutes
|
||||
schedules: []
|
||||
]
|
||||
|
||||
# ============================================================================
|
||||
# Constants
|
||||
# ============================================================================
|
||||
|
||||
@map_ids [109040000, 109040001, 109040002, 109040003, 109040004]
|
||||
@event_duration 600_000 # 10 minutes in ms
|
||||
@message_interval 60_000 # Broadcast messages every minute
|
||||
|
||||
# Message schedule based on time remaining
|
||||
@messages [
|
||||
{10_000, "You have 10 sec left. Those of you unable to beat the game, we hope you beat it next time! Great job everyone!! See you later~"},
|
||||
{110_000, "Alright, you don't have much time remaining. Please hurry up a little!"},
|
||||
{210_000, "The 4th stage is the last one for [The Maple Physical Fitness Test]. Please don't give up at the last minute and try your best. The reward is waiting for you at the very top!"},
|
||||
{310_000, "The 3rd stage offers traps where you may see them, but you won't be able to step on them. Please be careful of them as you make your way up."},
|
||||
{400_000, "For those who have heavy lags, please make sure to move slowly to avoid falling all the way down because of lags."},
|
||||
{500_000, "Please remember that if you die during the event, you'll be eliminated from the game. If you're running out of HP, either take a potion or recover HP first before moving on."},
|
||||
{600_000, "The most important thing you'll need to know to avoid the bananas thrown by the monkeys is *Timing* Timing is everything in this!"},
|
||||
{660_000, "The 2nd stage offers monkeys throwing bananas. Please make sure to avoid them by moving along at just the right timing."},
|
||||
{700_000, "Please remember that if you die during the event, you'll be eliminated from the game. You still have plenty of time left, so either take a potion or recover HP first before moving on."},
|
||||
{780_000, "Everyone that clears [The Maple Physical Fitness Test] on time will be given an item, regardless of the order of finish, so just relax, take your time, and clear the 4 stages."},
|
||||
{840_000, "There may be a heavy lag due to many users at stage 1 all at once. It won't be difficult, so please make sure not to fall down because of heavy lag."},
|
||||
{900_000, "[MapleStory Physical Fitness Test] consists of 4 stages, and if you happen to die during the game, you'll be eliminated from the game, so please be careful of that."}
|
||||
]
|
||||
|
||||
# ============================================================================
|
||||
# Event Implementation
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Creates a new Fitness event for the given channel.
|
||||
"""
|
||||
def new(channel_id) do
|
||||
base = Event.new(:fitness, channel_id, @map_ids)
|
||||
|
||||
%__MODULE__{
|
||||
base: base,
|
||||
time_started: nil,
|
||||
event_duration: @event_duration,
|
||||
schedules: []
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the map IDs for this event type.
|
||||
"""
|
||||
def map_ids, do: @map_ids
|
||||
|
||||
@doc """
|
||||
Resets the event state for a new game.
|
||||
"""
|
||||
def reset(%__MODULE__{} = event) do
|
||||
# Cancel existing schedules
|
||||
cancel_schedules(event)
|
||||
|
||||
base = %{event.base | is_running: true, player_count: 0}
|
||||
|
||||
%__MODULE__{
|
||||
event |
|
||||
base: base,
|
||||
time_started: nil,
|
||||
schedules: []
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Cleans up the event after it ends.
|
||||
"""
|
||||
def unreset(%__MODULE__{} = event) do
|
||||
cancel_schedules(event)
|
||||
|
||||
# Close entry portal
|
||||
set_portal_state(event, "join00", false)
|
||||
|
||||
base = %{event.base | is_running: false, player_count: 0}
|
||||
|
||||
%__MODULE__{
|
||||
event |
|
||||
base: base,
|
||||
time_started: nil,
|
||||
schedules: []
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Called when a player finishes (reaches end map).
|
||||
Gives prize and achievement.
|
||||
"""
|
||||
def finished(%__MODULE__{} = event, character) do
|
||||
Logger.info("Player #{character.name} finished Fitness event!")
|
||||
|
||||
# Give prize
|
||||
Event.give_prize(character)
|
||||
|
||||
# Give achievement (ID 20)
|
||||
Character.finish_achievement(character, 20)
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Called when a player loads into an event map.
|
||||
Sends clock if timer is running.
|
||||
"""
|
||||
def on_map_load(%__MODULE__{} = event, character) do
|
||||
if is_timer_started(event) do
|
||||
time_left = get_time_left(event)
|
||||
Logger.debug("Sending fitness clock to #{character.name}: #{div(time_left, 1000)}s remaining")
|
||||
# Send clock packet with time left
|
||||
end
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Starts the fitness event gameplay.
|
||||
"""
|
||||
def start_event(%__MODULE__{} = event) do
|
||||
Logger.info("Starting Fitness event on channel #{event.base.channel_id}")
|
||||
|
||||
now = System.system_time(:millisecond)
|
||||
|
||||
# Open entry portal
|
||||
set_portal_state(event, "join00", true)
|
||||
|
||||
# Broadcast start
|
||||
Event.broadcast_to_event(event.base, :event_started)
|
||||
Event.broadcast_to_event(event.base, {:clock, div(@event_duration, 1000)})
|
||||
|
||||
# Schedule event end
|
||||
end_ref = EventTimer.schedule(
|
||||
fn -> end_event(event) end,
|
||||
@event_duration
|
||||
)
|
||||
|
||||
# Start message broadcasting
|
||||
msg_ref = start_message_schedule(event)
|
||||
|
||||
%__MODULE__{
|
||||
event |
|
||||
time_started: now,
|
||||
schedules: [end_ref, msg_ref]
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if the timer has started.
|
||||
"""
|
||||
def is_timer_started(%__MODULE__{time_started: nil}), do: false
|
||||
def is_timer_started(%__MODULE__{}), do: true
|
||||
|
||||
@doc """
|
||||
Gets the total event duration in milliseconds.
|
||||
"""
|
||||
def get_time(%__MODULE__{event_duration: duration}), do: duration
|
||||
|
||||
@doc """
|
||||
Gets the time remaining in milliseconds.
|
||||
"""
|
||||
def get_time_left(%__MODULE__{time_started: nil}), do: @event_duration
|
||||
def get_time_left(%__MODULE__{time_started: started, event_duration: duration}) do
|
||||
elapsed = System.system_time(:millisecond) - started
|
||||
max(0, duration - elapsed)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the time elapsed in milliseconds.
|
||||
"""
|
||||
def get_time_elapsed(%__MODULE__{time_started: nil}), do: 0
|
||||
def get_time_elapsed(%__MODULE__{time_started: started}) do
|
||||
System.system_time(:millisecond) - started
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if a player is eliminated (died during event).
|
||||
"""
|
||||
def eliminated?(character) do
|
||||
# Check if character died while on event maps
|
||||
# This would check character state
|
||||
character.hp <= 0
|
||||
end
|
||||
|
||||
@doc """
|
||||
Eliminates a player from the event.
|
||||
"""
|
||||
def eliminate_player(%__MODULE__{} = event, character) do
|
||||
Logger.info("Player #{character.name} eliminated from Fitness event")
|
||||
|
||||
# Warp player out
|
||||
Event.warp_back(character)
|
||||
|
||||
# Unregister from event
|
||||
base = Event.unregister_player(event.base, character.id)
|
||||
|
||||
%{event | base: base}
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Private Functions
|
||||
# ============================================================================
|
||||
|
||||
defp cancel_schedules(%__MODULE__{schedules: schedules} = event) do
|
||||
Enum.each(schedules, fn ref ->
|
||||
EventTimer.cancel(ref)
|
||||
end)
|
||||
|
||||
%{event | schedules: []}
|
||||
end
|
||||
|
||||
defp start_message_schedule(%__MODULE__{} = event) do
|
||||
# Register recurring task for message broadcasting
|
||||
{:ok, ref} = EventTimer.register(
|
||||
fn -> check_and_broadcast_messages(event) end,
|
||||
@message_interval,
|
||||
0
|
||||
)
|
||||
|
||||
ref
|
||||
end
|
||||
|
||||
defp check_and_broadcast_messages(%__MODULE__{} = event) do
|
||||
time_left = get_time_left(event)
|
||||
|
||||
# Find messages that should be broadcast based on time left
|
||||
messages_to_send = Enum.filter(@messages, fn {threshold, _} ->
|
||||
time_left <= threshold and time_left > threshold - @message_interval
|
||||
end)
|
||||
|
||||
Enum.each(messages_to_send, fn {_, message} ->
|
||||
Event.broadcast_to_event(event.base, {:server_notice, message})
|
||||
end)
|
||||
end
|
||||
|
||||
defp set_portal_state(%__MODULE__{}, _portal_name, _state) do
|
||||
# In real implementation, this would update the map portal state
|
||||
# allowing or preventing players from entering
|
||||
:ok
|
||||
end
|
||||
|
||||
defp end_event(%__MODULE__{} = event) do
|
||||
Logger.info("Fitness event ended on channel #{event.base.channel_id}")
|
||||
|
||||
# Warp out all remaining players
|
||||
# In real implementation:
|
||||
# - Get all players on event maps
|
||||
# - Warp each back to saved location
|
||||
|
||||
# Unreset event
|
||||
unreset(event)
|
||||
end
|
||||
end
|
||||
332
lib/odinsea/game/events/ola_ola.ex
Normal file
332
lib/odinsea/game/events/ola_ola.ex
Normal file
@@ -0,0 +1,332 @@
|
||||
defmodule Odinsea.Game.Events.OlaOla do
|
||||
@moduledoc """
|
||||
Ola Ola Event - Portal guessing game (similar to Survival but with portals).
|
||||
Ported from Java `server.events.MapleOla`.
|
||||
|
||||
## Gameplay
|
||||
- 3 stages with random correct portals
|
||||
- Players must guess which portal leads forward
|
||||
- Wrong portals send players back or eliminate them
|
||||
- Fastest to finish wins
|
||||
|
||||
## Maps
|
||||
- Stage 1: 109030001 (5 portals: ch00-ch04)
|
||||
- Stage 2: 109030002 (8 portals: ch00-ch07)
|
||||
- Stage 3: 109030003 (16 portals: ch00-ch15)
|
||||
|
||||
## Win Condition
|
||||
- Reach the finish map by choosing correct portals
|
||||
- First to finish gets best prize, all finishers get prize
|
||||
"""
|
||||
|
||||
alias Odinsea.Game.Event
|
||||
alias Odinsea.Game.Timer.EventTimer
|
||||
alias Odinsea.Game.Character
|
||||
|
||||
require Logger
|
||||
|
||||
# ============================================================================
|
||||
# Types
|
||||
# ============================================================================
|
||||
|
||||
@typedoc "OlaOla event state"
|
||||
@type t :: %__MODULE__{
|
||||
base: Event.t(),
|
||||
stages: [non_neg_integer()], # Correct portal indices for each stage
|
||||
time_started: integer() | nil,
|
||||
event_duration: non_neg_integer(),
|
||||
schedules: [reference()]
|
||||
}
|
||||
|
||||
defstruct [
|
||||
:base,
|
||||
stages: [0, 0, 0], # Will be randomized on start
|
||||
time_started: nil,
|
||||
event_duration: 360_000, # 6 minutes
|
||||
schedules: []
|
||||
]
|
||||
|
||||
# ============================================================================
|
||||
# Constants
|
||||
# ============================================================================
|
||||
|
||||
@map_ids [109030001, 109030002, 109030003]
|
||||
@event_duration 360_000 # 6 minutes in ms
|
||||
|
||||
# Stage configurations
|
||||
@stage_config [
|
||||
%{map: 109030001, portals: 5, prefix: "ch"}, # Stage 1: 5 portals
|
||||
%{map: 109030002, portals: 8, prefix: "ch"}, # Stage 2: 8 portals
|
||||
%{map: 109030003, portals: 16, prefix: "ch"} # Stage 3: 16 portals
|
||||
]
|
||||
|
||||
# ============================================================================
|
||||
# Event Implementation
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Creates a new OlaOla event for the given channel.
|
||||
"""
|
||||
def new(channel_id) do
|
||||
base = Event.new(:ola_ola, channel_id, @map_ids)
|
||||
|
||||
%__MODULE__{
|
||||
base: base,
|
||||
stages: [0, 0, 0],
|
||||
time_started: nil,
|
||||
event_duration: @event_duration,
|
||||
schedules: []
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the map IDs for this event type.
|
||||
"""
|
||||
def map_ids, do: @map_ids
|
||||
|
||||
@doc """
|
||||
Resets the event state for a new game.
|
||||
"""
|
||||
def reset(%__MODULE__{} = event) do
|
||||
# Cancel existing schedules
|
||||
cancel_schedules(event)
|
||||
|
||||
base = %{event.base | is_running: true, player_count: 0}
|
||||
|
||||
%__MODULE__{
|
||||
event |
|
||||
base: base,
|
||||
stages: [0, 0, 0],
|
||||
time_started: nil,
|
||||
schedules: []
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Cleans up the event after it ends.
|
||||
Randomizes correct portals for next game.
|
||||
"""
|
||||
def unreset(%__MODULE__{} = event) do
|
||||
cancel_schedules(event)
|
||||
|
||||
# Randomize correct portals for each stage
|
||||
stages = [
|
||||
random_stage_portal(0), # Stage 1: 0-4
|
||||
random_stage_portal(1), # Stage 2: 0-7
|
||||
random_stage_portal(2) # Stage 3: 0-15
|
||||
]
|
||||
|
||||
# Hack check: stage 1 portal 2 is inaccessible
|
||||
stages = if Enum.at(stages, 0) == 2 do
|
||||
List.replace_at(stages, 0, 3)
|
||||
else
|
||||
stages
|
||||
end
|
||||
|
||||
# Open entry portal
|
||||
set_portal_state(event, "join00", true)
|
||||
|
||||
base = %{event.base | is_running: false, player_count: 0}
|
||||
|
||||
%__MODULE__{
|
||||
event |
|
||||
base: base,
|
||||
stages: stages,
|
||||
time_started: nil,
|
||||
schedules: []
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Called when a player finishes (reaches end map).
|
||||
Gives prize and achievement.
|
||||
"""
|
||||
def finished(%__MODULE__{} = event, character) do
|
||||
Logger.info("Player #{character.name} finished Ola Ola event!")
|
||||
|
||||
# Give prize
|
||||
Event.give_prize(character)
|
||||
|
||||
# Give achievement (ID 21)
|
||||
Character.finish_achievement(character, 21)
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Called when a player loads into an event map.
|
||||
Sends clock if timer is running.
|
||||
"""
|
||||
def on_map_load(%__MODULE__{} = event, character) do
|
||||
if is_timer_started(event) do
|
||||
time_left = get_time_left(event)
|
||||
Logger.debug("Sending Ola Ola clock to #{character.name}: #{div(time_left, 1000)}s remaining")
|
||||
end
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Starts the Ola Ola event gameplay.
|
||||
"""
|
||||
def start_event(%__MODULE__{} = event) do
|
||||
Logger.info("Starting Ola Ola event on channel #{event.base.channel_id}")
|
||||
|
||||
now = System.system_time(:millisecond)
|
||||
|
||||
# Close entry portal
|
||||
set_portal_state(event, "join00", false)
|
||||
|
||||
# Broadcast start
|
||||
Event.broadcast_to_event(event.base, :event_started)
|
||||
Event.broadcast_to_event(event.base, {:clock, div(@event_duration, 1000)})
|
||||
Event.broadcast_to_event(event.base, {:server_notice, "The portal has now opened. Press the up arrow key at the portal to enter."})
|
||||
Event.broadcast_to_event(event.base, {:server_notice, "Fall down once, and never get back up again! Get to the top without falling down!"})
|
||||
|
||||
# Schedule event end
|
||||
end_ref = EventTimer.schedule(
|
||||
fn -> end_event(event) end,
|
||||
@event_duration
|
||||
)
|
||||
|
||||
%__MODULE__{
|
||||
event |
|
||||
time_started: now,
|
||||
schedules: [end_ref]
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if a character chose the correct portal for their current stage.
|
||||
|
||||
## Parameters
|
||||
- event: The OlaOla event state
|
||||
- portal_name: The portal name (e.g., "ch00", "ch05")
|
||||
- map_id: Current map ID
|
||||
|
||||
## Returns
|
||||
- true if correct portal
|
||||
- false if wrong portal
|
||||
"""
|
||||
def correct_portal?(%__MODULE__{stages: stages}, portal_name, map_id) do
|
||||
# Get stage index from map ID
|
||||
stage_index = get_stage_index(map_id)
|
||||
|
||||
if stage_index == nil do
|
||||
false
|
||||
else
|
||||
# Get correct portal for this stage
|
||||
correct = Enum.at(stages, stage_index)
|
||||
|
||||
# Format correct portal name
|
||||
correct_name = format_portal_name(correct)
|
||||
|
||||
portal_name == correct_name
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the correct portal name for a stage.
|
||||
"""
|
||||
def get_correct_portal(%__MODULE__{stages: stages}, stage_index) when stage_index in 0..2 do
|
||||
correct = Enum.at(stages, stage_index)
|
||||
format_portal_name(correct)
|
||||
end
|
||||
|
||||
def get_correct_portal(_, _), do: nil
|
||||
|
||||
@doc """
|
||||
Checks if the timer has started.
|
||||
"""
|
||||
def is_timer_started(%__MODULE__{time_started: nil}), do: false
|
||||
def is_timer_started(%__MODULE__{}), do: true
|
||||
|
||||
@doc """
|
||||
Gets the total event duration in milliseconds.
|
||||
"""
|
||||
def get_time(%__MODULE__{event_duration: duration}), do: duration
|
||||
|
||||
@doc """
|
||||
Gets the time remaining in milliseconds.
|
||||
"""
|
||||
def get_time_left(%__MODULE__{time_started: nil}), do: @event_duration
|
||||
def get_time_left(%__MODULE__{time_started: started, event_duration: duration}) do
|
||||
elapsed = System.system_time(:millisecond) - started
|
||||
max(0, duration - elapsed)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the current stage (0-2) for a map ID.
|
||||
"""
|
||||
def get_stage_index(map_id) do
|
||||
Enum.find_index(@map_ids, &(&1 == map_id))
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the stage configuration.
|
||||
"""
|
||||
def stage_config, do: @stage_config
|
||||
|
||||
@doc """
|
||||
Handles a player attempting to use a portal.
|
||||
Returns {:ok, destination_map} for correct portal, :error for wrong portal.
|
||||
"""
|
||||
def attempt_portal(%__MODULE__{} = event, portal_name, current_map_id) do
|
||||
if correct_portal?(event, portal_name, current_map_id) do
|
||||
# Correct portal - advance to next stage
|
||||
stage = get_stage_index(current_map_id)
|
||||
|
||||
if stage < 2 do
|
||||
next_map = Enum.at(@map_ids, stage + 1)
|
||||
{:ok, next_map}
|
||||
else
|
||||
# Finished all stages
|
||||
{:finished, 109050000} # Finish map
|
||||
end
|
||||
else
|
||||
# Wrong portal - fail
|
||||
:error
|
||||
end
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Private Functions
|
||||
# ============================================================================
|
||||
|
||||
defp random_stage_portal(stage_index) do
|
||||
portal_count = Enum.at(@stage_config, stage_index).portals
|
||||
:rand.uniform(portal_count) - 1 # 0-based
|
||||
end
|
||||
|
||||
defp format_portal_name(portal_num) do
|
||||
# Format as ch00, ch01, etc.
|
||||
if portal_num < 10 do
|
||||
"ch0#{portal_num}"
|
||||
else
|
||||
"ch#{portal_num}"
|
||||
end
|
||||
end
|
||||
|
||||
defp cancel_schedules(%__MODULE__{schedules: schedules} = event) do
|
||||
Enum.each(schedules, fn ref ->
|
||||
EventTimer.cancel(ref)
|
||||
end)
|
||||
|
||||
%{event | schedules: []}
|
||||
end
|
||||
|
||||
defp set_portal_state(%__MODULE__{}, _portal_name, _state) do
|
||||
# In real implementation, update map portal state
|
||||
:ok
|
||||
end
|
||||
|
||||
defp end_event(%__MODULE__{} = event) do
|
||||
Logger.info("Ola Ola event ended on channel #{event.base.channel_id}")
|
||||
|
||||
# Warp out all remaining players
|
||||
# In real implementation, get all players on event maps and warp them
|
||||
|
||||
# Unreset event
|
||||
unreset(event)
|
||||
end
|
||||
end
|
||||
349
lib/odinsea/game/events/ox_quiz.ex
Normal file
349
lib/odinsea/game/events/ox_quiz.ex
Normal file
@@ -0,0 +1,349 @@
|
||||
defmodule Odinsea.Game.Events.OxQuiz do
|
||||
@moduledoc """
|
||||
OX Quiz Event - True/False quiz game with position-based answers.
|
||||
Ported from Java `server.events.MapleOxQuiz`.
|
||||
|
||||
## Gameplay
|
||||
- 10 questions are asked
|
||||
- Players stand on O (true/right side) or X (false/left side) side
|
||||
- Wrong answer = eliminated (HP set to 0)
|
||||
- Correct answer = gain EXP
|
||||
- Last players standing win
|
||||
|
||||
## Maps
|
||||
- Single map: 109020001
|
||||
- X side: x < -234, y > -26
|
||||
- O side: x > -234, y > -26
|
||||
|
||||
## Win Condition
|
||||
- Answer correctly to survive all 10 questions
|
||||
- Remaining players at end get prize
|
||||
"""
|
||||
|
||||
alias Odinsea.Game.Event
|
||||
alias Odinsea.Game.Timer.EventTimer
|
||||
alias Odinsea.Game.Events.OxQuizQuestions
|
||||
|
||||
require Logger
|
||||
|
||||
# ============================================================================
|
||||
# Types
|
||||
# ============================================================================
|
||||
|
||||
@typedoc "OX Quiz event state"
|
||||
@type t :: %__MODULE__{
|
||||
base: Event.t(),
|
||||
times_asked: non_neg_integer(),
|
||||
current_question: OxQuizQuestions.question() | nil,
|
||||
finished: boolean(),
|
||||
question_delay: non_neg_integer(), # ms before showing question
|
||||
answer_delay: non_neg_integer(), # ms before revealing answer
|
||||
schedules: [reference()]
|
||||
}
|
||||
|
||||
defstruct [
|
||||
:base,
|
||||
times_asked: 0,
|
||||
current_question: nil,
|
||||
finished: false,
|
||||
question_delay: 10_000,
|
||||
answer_delay: 10_000,
|
||||
schedules: []
|
||||
]
|
||||
|
||||
# ============================================================================
|
||||
# Constants
|
||||
# ============================================================================
|
||||
|
||||
@map_ids [109020001]
|
||||
@max_questions 10
|
||||
|
||||
# Position boundaries for O (true) vs X (false)
|
||||
@o_side_bounds %{x_min: -234, x_max: 9999, y_min: -26, y_max: 9999}
|
||||
@x_side_bounds %{x_min: -9999, x_max: -234, y_min: -26, y_max: 9999}
|
||||
|
||||
# ============================================================================
|
||||
# Event Implementation
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Creates a new OX Quiz event for the given channel.
|
||||
"""
|
||||
def new(channel_id) do
|
||||
base = Event.new(:ox_quiz, channel_id, @map_ids)
|
||||
|
||||
%__MODULE__{
|
||||
base: base,
|
||||
times_asked: 0,
|
||||
current_question: nil,
|
||||
finished: false,
|
||||
question_delay: 10_000,
|
||||
answer_delay: 10_000,
|
||||
schedules: []
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the map IDs for this event type.
|
||||
"""
|
||||
def map_ids, do: @map_ids
|
||||
|
||||
@doc """
|
||||
Resets the event state for a new game.
|
||||
"""
|
||||
def reset(%__MODULE__{} = event) do
|
||||
# Cancel existing schedules
|
||||
cancel_schedules(event)
|
||||
|
||||
# Close entry portal
|
||||
set_portal_state(event, "join00", false)
|
||||
|
||||
base = %{event.base | is_running: true, player_count: 0}
|
||||
|
||||
%__MODULE__{
|
||||
event |
|
||||
base: base,
|
||||
times_asked: 0,
|
||||
current_question: nil,
|
||||
finished: false,
|
||||
schedules: []
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Cleans up the event after it ends.
|
||||
"""
|
||||
def unreset(%__MODULE__{} = event) do
|
||||
cancel_schedules(event)
|
||||
|
||||
# Open entry portal
|
||||
set_portal_state(event, "join00", true)
|
||||
|
||||
base = %{event.base | is_running: false, player_count: 0}
|
||||
|
||||
%__MODULE__{
|
||||
event |
|
||||
base: base,
|
||||
times_asked: 0,
|
||||
current_question: nil,
|
||||
finished: false,
|
||||
schedules: []
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Called when a player finishes.
|
||||
OX Quiz doesn't use this - winners determined by survival.
|
||||
"""
|
||||
def finished(_event, _character) do
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Called when a player loads into the event map.
|
||||
Unmutes player (allows chat during quiz).
|
||||
"""
|
||||
def on_map_load(%__MODULE__{} = _event, character) do
|
||||
# Unmute player (allow chat)
|
||||
# In real implementation: Character.set_temp_mute(character, false)
|
||||
Logger.debug("Player #{character.name} loaded OX Quiz map, unmuting")
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Starts the OX Quiz event gameplay.
|
||||
Begins asking questions.
|
||||
"""
|
||||
def start_event(%__MODULE__{} = event) do
|
||||
Logger.info("Starting OX Quiz event on channel #{event.base.channel_id}")
|
||||
|
||||
# Close entry portal
|
||||
set_portal_state(event, "join00", false)
|
||||
|
||||
# Start asking questions
|
||||
send_question(%{event | finished: false})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Sends the next question to all players.
|
||||
"""
|
||||
def send_question(%__MODULE__{finished: true} = event), do: event
|
||||
|
||||
def send_question(%__MODULE__{} = event) do
|
||||
# Grab random question
|
||||
question = OxQuizQuestions.get_random_question()
|
||||
|
||||
# Schedule question display
|
||||
question_ref = EventTimer.schedule(
|
||||
fn -> display_question(event, question) end,
|
||||
event.question_delay
|
||||
)
|
||||
|
||||
# Schedule answer reveal
|
||||
answer_ref = EventTimer.schedule(
|
||||
fn -> reveal_answer(event, question) end,
|
||||
event.question_delay + event.answer_delay
|
||||
)
|
||||
|
||||
%__MODULE__{
|
||||
event |
|
||||
current_question: question,
|
||||
schedules: [question_ref, answer_ref | event.schedules]
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Displays the question to all players.
|
||||
"""
|
||||
def display_question(%__MODULE__{finished: true}, _question), do: :ok
|
||||
|
||||
def display_question(%__MODULE__{} = event, question) do
|
||||
# Check end conditions
|
||||
if should_end_event?(event) do
|
||||
end_event(event)
|
||||
else
|
||||
# Broadcast question
|
||||
{question_set, question_id} = question.ids
|
||||
Event.broadcast_to_event(event.base, {:ox_quiz_show, question_set, question_id, true})
|
||||
Event.broadcast_to_event(event.base, {:clock, 10}) # 10 seconds to answer
|
||||
|
||||
Logger.debug("OX Quiz: Displaying question #{question_id} from set #{question_set}")
|
||||
end
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Reveals the answer and processes results.
|
||||
"""
|
||||
def reveal_answer(%__MODULE__{finished: true}, _question), do: :ok
|
||||
|
||||
def reveal_answer(%__MODULE__{} = event, question) do
|
||||
if event.finished do
|
||||
:ok
|
||||
else
|
||||
# Broadcast answer reveal
|
||||
{question_set, question_id} = question.ids
|
||||
Event.broadcast_to_event(event.base, {:ox_quiz_hide, question_set, question_id})
|
||||
|
||||
# Process each player
|
||||
# In real implementation:
|
||||
# - Get all players on map
|
||||
# - Check their position vs answer
|
||||
# - Wrong position: set HP to 0
|
||||
# - Correct position: give EXP
|
||||
|
||||
Logger.debug("OX Quiz: Revealing answer for question #{question_id}: #{question.answer}")
|
||||
|
||||
# Increment question count
|
||||
event = %{event | times_asked: event.times_asked + 1}
|
||||
|
||||
# Continue to next question
|
||||
send_question(event)
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if a player's position corresponds to the correct answer.
|
||||
|
||||
## Parameters
|
||||
- answer: :o (true) or :x (false)
|
||||
- x: Player X position
|
||||
- y: Player Y position
|
||||
|
||||
## Returns
|
||||
- true if position matches answer
|
||||
- false if wrong position
|
||||
"""
|
||||
def correct_position?(:o, x, y) do
|
||||
x > -234 and y > -26
|
||||
end
|
||||
|
||||
def correct_position?(:x, x, y) do
|
||||
x < -234 and y > -26
|
||||
end
|
||||
|
||||
@doc """
|
||||
Processes a player's answer based on their position.
|
||||
Returns {:correct, exp} or {:wrong, 0}
|
||||
"""
|
||||
def check_player_answer(question_answer, player_x, player_y) do
|
||||
player_answer = if player_x > -234, do: :o, else: :x
|
||||
|
||||
if player_answer == question_answer do
|
||||
{:correct, 3000} # 3000 EXP for correct answer
|
||||
else
|
||||
{:wrong, 0}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the current question number.
|
||||
"""
|
||||
def current_question_number(%__MODULE__{times_asked: asked}), do: asked + 1
|
||||
|
||||
@doc """
|
||||
Gets the maximum number of questions.
|
||||
"""
|
||||
def max_questions, do: @max_questions
|
||||
|
||||
@doc """
|
||||
Mutes a player (after event ends).
|
||||
"""
|
||||
def mute_player(character) do
|
||||
# In real implementation: Character.set_temp_mute(character, true)
|
||||
Logger.debug("Muting player #{character.name}")
|
||||
:ok
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Private Functions
|
||||
# ============================================================================
|
||||
|
||||
defp should_end_event?(%__MODULE__{} = event) do
|
||||
# End if 10 questions asked or only 1 player left
|
||||
event.times_asked >= @max_questions or count_alive_players(event) <= 1
|
||||
end
|
||||
|
||||
defp count_alive_players(%__MODULE__{} = _event) do
|
||||
# In real implementation:
|
||||
# - Get all players on map
|
||||
# - Count non-GM, alive players
|
||||
# For now, return placeholder
|
||||
10
|
||||
end
|
||||
|
||||
defp cancel_schedules(%__MODULE__{schedules: schedules} = event) do
|
||||
Enum.each(schedules, fn ref ->
|
||||
EventTimer.cancel(ref)
|
||||
end)
|
||||
|
||||
%{event | schedules: []}
|
||||
end
|
||||
|
||||
defp set_portal_state(%__MODULE__{}, _portal_name, _state) do
|
||||
# In real implementation, update map portal state
|
||||
:ok
|
||||
end
|
||||
|
||||
defp end_event(%__MODULE__{} = event) do
|
||||
Logger.info("OX Quiz event ended on channel #{event.base.channel_id}")
|
||||
|
||||
# Mark as finished
|
||||
event = %{event | finished: true}
|
||||
|
||||
# Broadcast end
|
||||
Event.broadcast_to_event(event.base, {:server_notice, "The event has ended"})
|
||||
|
||||
# Process winners
|
||||
# In real implementation:
|
||||
# - Get all alive, non-GM players
|
||||
# - Give prize to each
|
||||
# - Give achievement (ID 19)
|
||||
# - Mute players
|
||||
# - Warp back
|
||||
|
||||
# Unreset event
|
||||
unreset(event)
|
||||
end
|
||||
end
|
||||
283
lib/odinsea/game/events/ox_quiz_questions.ex
Normal file
283
lib/odinsea/game/events/ox_quiz_questions.ex
Normal file
@@ -0,0 +1,283 @@
|
||||
defmodule Odinsea.Game.Events.OxQuizQuestions do
|
||||
@moduledoc """
|
||||
OX Quiz Question Database.
|
||||
Ported from Java `server.events.MapleOxQuizFactory`.
|
||||
|
||||
Stores true/false questions loaded from database or fallback data.
|
||||
Questions are organized into sets and IDs for efficient lookup.
|
||||
|
||||
## Question Format
|
||||
- question: The question text
|
||||
- display: How to display the answer (O/X)
|
||||
- answer: :o for true, :x for false
|
||||
- question_set: Category/set number
|
||||
- question_id: ID within the set
|
||||
"""
|
||||
|
||||
require Logger
|
||||
|
||||
# ============================================================================
|
||||
# Types
|
||||
# ============================================================================
|
||||
|
||||
@typedoc "OX Quiz question struct"
|
||||
@type question :: %{
|
||||
question: String.t(),
|
||||
display: String.t(),
|
||||
answer: :o | :x,
|
||||
ids: {non_neg_integer(), non_neg_integer()} # {question_set, question_id}
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# GenServer State
|
||||
# ============================================================================
|
||||
|
||||
use GenServer
|
||||
|
||||
defstruct [
|
||||
:questions, # Map of {{set, id} => question}
|
||||
:ets_table
|
||||
]
|
||||
|
||||
# ============================================================================
|
||||
# Client API
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Starts the OX Quiz question cache.
|
||||
"""
|
||||
def start_link(_opts) do
|
||||
GenServer.start_link(__MODULE__, [], name: __MODULE__)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a random question from the database.
|
||||
"""
|
||||
def get_random_question do
|
||||
GenServer.call(__MODULE__, :get_random_question)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a specific question by set and ID.
|
||||
"""
|
||||
def get_question(question_set, question_id) do
|
||||
GenServer.call(__MODULE__, {:get_question, question_set, question_id})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets all questions.
|
||||
"""
|
||||
def get_all_questions do
|
||||
GenServer.call(__MODULE__, :get_all_questions)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the total number of questions.
|
||||
"""
|
||||
def question_count do
|
||||
GenServer.call(__MODULE__, :question_count)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Reloads questions from database.
|
||||
"""
|
||||
def reload do
|
||||
GenServer.cast(__MODULE__, :reload)
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Server Callbacks
|
||||
# ============================================================================
|
||||
|
||||
@impl true
|
||||
def init(_) do
|
||||
# Create ETS table for fast lookups
|
||||
ets = :ets.new(:ox_quiz_questions, [:set, :protected, :named_table])
|
||||
|
||||
# Load initial questions
|
||||
questions = load_questions()
|
||||
|
||||
# Store in ETS
|
||||
Enum.each(questions, fn {key, q} ->
|
||||
:ets.insert(ets, {key, q})
|
||||
end)
|
||||
|
||||
Logger.info("OX Quiz Questions loaded: #{map_size(questions)} questions")
|
||||
|
||||
{:ok, %__MODULE__{questions: questions, ets_table: ets}}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call(:get_random_question, _from, state) do
|
||||
question = get_random_question_impl(state)
|
||||
{:reply, question, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:get_question, set, id}, _from, state) do
|
||||
question = Map.get(state.questions, {set, id})
|
||||
{:reply, question, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call(:get_all_questions, _from, state) do
|
||||
{:reply, Map.values(state.questions), state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call(:question_count, _from, state) do
|
||||
{:reply, map_size(state.questions), state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_cast(:reload, state) do
|
||||
# Clear ETS
|
||||
:ets.delete_all_objects(state.ets_table)
|
||||
|
||||
# Reload questions
|
||||
questions = load_questions()
|
||||
|
||||
# Store in ETS
|
||||
Enum.each(questions, fn {key, q} ->
|
||||
:ets.insert(state.ets_table, {key, q})
|
||||
end)
|
||||
|
||||
Logger.info("OX Quiz Questions reloaded: #{map_size(questions)} questions")
|
||||
|
||||
{:noreply, %{state | questions: questions}}
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Private Functions
|
||||
# ============================================================================
|
||||
|
||||
defp get_random_question_impl(state) do
|
||||
questions = Map.values(state.questions)
|
||||
|
||||
if length(questions) > 0 do
|
||||
Enum.random(questions)
|
||||
else
|
||||
# Return fallback question if none loaded
|
||||
fallback_question()
|
||||
end
|
||||
end
|
||||
|
||||
defp load_questions do
|
||||
# Try to load from database
|
||||
# In real implementation:
|
||||
# - Query wz_oxdata table
|
||||
# - Parse each row into question struct
|
||||
|
||||
# For now, use fallback questions
|
||||
fallback_questions()
|
||||
|> Enum.map(fn q -> {{elem(q.ids, 0), elem(q.ids, 1)}, q} end)
|
||||
|> Map.new()
|
||||
end
|
||||
|
||||
defp parse_answer("o"), do: :o
|
||||
defp parse_answer("O"), do: :o
|
||||
defp parse_answer("x"), do: :x
|
||||
defp parse_answer("X"), do: :x
|
||||
defp parse_answer(_), do: :o # Default to true
|
||||
|
||||
# ============================================================================
|
||||
# Fallback Questions
|
||||
# ============================================================================
|
||||
|
||||
defp fallback_question do
|
||||
%{
|
||||
question: "MapleStory was first released in 2003?",
|
||||
display: "O",
|
||||
answer: :o,
|
||||
ids: {0, 0}
|
||||
}
|
||||
end
|
||||
|
||||
defp fallback_questions do
|
||||
[
|
||||
# Set 1: General MapleStory Knowledge
|
||||
%{question: "MapleStory was first released in 2003?", display: "O", answer: :o, ids: {1, 1}},
|
||||
%{question: "The maximum level in MapleStory is 200?", display: "O", answer: :o, ids: {1, 2}},
|
||||
%{question: "Henesys is the starting town for all beginners?", display: "X", answer: :x, ids: {1, 3}},
|
||||
%{question: "The Pink Bean is a boss monster?", display: "O", answer: :o, ids: {1, 4}},
|
||||
%{question: "Magicians use swords as their primary weapon?", display: "X", answer: :x, ids: {1, 5}},
|
||||
%{question: "The EXP curve gets steeper at higher levels?", display: "O", answer: :o, ids: {1, 6}},
|
||||
%{question: "Gachapon gives random items for NX?", display: "O", answer: :o, ids: {1, 7}},
|
||||
%{question: "Warriors have the highest INT growth?", display: "X", answer: :x, ids: {1, 8}},
|
||||
%{question: "The Cash Shop sells permanent pets?", display: "O", answer: :o, ids: {1, 9}},
|
||||
%{question: "All monsters in Maple Island are passive?", display: "O", answer: :o, ids: {1, 10}},
|
||||
|
||||
# Set 2: Classes and Jobs
|
||||
%{question: "Beginners can use the Three Snails skill?", display: "O", answer: :o, ids: {2, 1}},
|
||||
%{question: "Magicians require the most DEX to advance?", display: "X", answer: :x, ids: {2, 2}},
|
||||
%{question: "Thieves can use claws and daggers?", display: "O", answer: :o, ids: {2, 3}},
|
||||
%{question: "Pirates are the only class that can use guns?", display: "O", answer: :o, ids: {2, 4}},
|
||||
%{question: "Archers specialize in close-range combat?", display: "X", answer: :x, ids: {2, 5}},
|
||||
%{question: "First job advancement happens at level 10?", display: "O", answer: :o, ids: {2, 6}},
|
||||
%{question: "All classes can use magic attacks?", display: "X", answer: :x, ids: {2, 7}},
|
||||
%{question: "Bowmen require arrows to attack?", display: "O", answer: :o, ids: {2, 8}},
|
||||
%{question: "Warriors have the highest HP pool?", display: "O", answer: :o, ids: {2, 9}},
|
||||
%{question: "Cygnus Knights are available at level 1?", display: "X", answer: :x, ids: {2, 10}},
|
||||
|
||||
# Set 3: Monsters and Maps
|
||||
%{question: "Blue Snails are found on Maple Island?", display: "O", answer: :o, ids: {3, 1}},
|
||||
%{question: "Zakum is located in the Dead Mine?", display: "O", answer: :o, ids: {3, 2}},
|
||||
%{question: "Pigs drop pork items?", display: "O", answer: :o, ids: {3, 3}},
|
||||
%{question: "The highest level map is Victoria Island?", display: "X", answer: :x, ids: {3, 4}},
|
||||
%{question: "Balrog is a level 100 boss?", display: "O", answer: :o, ids: {3, 5}},
|
||||
%{question: "Mushmom is a giant mushroom monster?", display: "O", answer: :o, ids: {3, 6}},
|
||||
%{question: "All monsters respawn immediately after death?", display: "X", answer: :x, ids: {3, 7}},
|
||||
%{question: "Jr. Balrog spawns in Sleepywood Dungeon?", display: "O", answer: :o, ids: {3, 8}},
|
||||
%{question: "Orbis Tower connects Orbis to El Nath?", display: "O", answer: :o, ids: {3, 9}},
|
||||
%{question: "Ludibrium is a town made of toys?", display: "O", answer: :o, ids: {3, 10}},
|
||||
|
||||
# Set 4: Items and Equipment
|
||||
%{question: "Equipment can have potential stats?", display: "O", answer: :o, ids: {4, 1}},
|
||||
%{question: "Mesos are the currency of MapleStory?", display: "O", answer: :o, ids: {4, 2}},
|
||||
%{question: "Scrolls always succeed?", display: "X", answer: :x, ids: {4, 3}},
|
||||
%{question: "Potions restore HP and MP?", display: "O", answer: :o, ids: {4, 4}},
|
||||
%{question: " NX Cash is required to buy Cash Shop items?", display: "O", answer: :o, ids: {4, 5}},
|
||||
%{question: "All equipment can be traded?", display: "X", answer: :x, ids: {4, 6}},
|
||||
%{question: "Stars are thrown by Night Lords?", display: "O", answer: :o, ids: {4, 7}},
|
||||
%{question: "Beginners can equip level 100 items?", display: "X", answer: :x, ids: {4, 8}},
|
||||
%{question: "Clean Slate Scrolls remove failed slots?", display: "O", answer: :o, ids: {4, 9}},
|
||||
%{question: "Chaos Scrolls randomize item stats?", display: "O", answer: :o, ids: {4, 10}},
|
||||
|
||||
# Set 5: Quests and NPCs
|
||||
%{question: "Mai is the first quest NPC beginners meet?", display: "O", answer: :o, ids: {5, 1}},
|
||||
%{question: "All quests can be repeated?", display: "X", answer: :x, ids: {5, 2}},
|
||||
%{question: "NPCs with \"!\" above them give quests?", display: "O", answer: :o, ids: {5, 3}},
|
||||
%{question: "Party quests require exactly 6 players?", display: "X", answer: :x, ids: {5, 4}},
|
||||
%{question: "Roger sells potions in Henesys?", display: "X", answer: :x, ids: {5, 5}},
|
||||
%{question: "The Lost City is another name for Kerning City?", display: "X", answer: :x, ids: {5, 6}},
|
||||
%{question: "Guilds can have up to 200 members?", display: "O", answer: :o, ids: {5, 7}},
|
||||
%{question: "All NPCs can be attacked?", display: "X", answer: :x, ids: {5, 8}},
|
||||
%{question: "Big Headward sells hairstyles?", display: "O", answer: :o, ids: {5, 9}},
|
||||
%{question: "The Storage Keeper stores items for free?", display: "X", answer: :x, ids: {5, 10}},
|
||||
|
||||
# Set 6: Game Mechanics
|
||||
%{question: "Fame can be given or taken once per day?", display: "O", answer: :o, ids: {6, 1}},
|
||||
%{question: "Party play gives bonus EXP?", display: "O", answer: :o, ids: {6, 2}},
|
||||
%{question: "Dying causes EXP loss?", display: "O", answer: :o, ids: {6, 3}},
|
||||
%{question: "All skills have no cooldown?", display: "X", answer: :x, ids: {6, 4}},
|
||||
%{question: "Trade window allows up to 9 items?", display: "O", answer: :o, ids: {6, 5}},
|
||||
%{question: "Mounting a pet requires level 70?", display: "X", answer: :x, ids: {6, 6}},
|
||||
%{question: "Monster Book tracks monster information?", display: "O", answer: :o, ids: {6, 7}},
|
||||
%{question: "Bosses have purple health bars?", display: "O", answer: :o, ids: {6, 8}},
|
||||
%{question: "Channel changing is instant?", display: "X", answer: :x, ids: {6, 9}},
|
||||
%{question: "Expedition mode is for large boss fights?", display: "O", answer: :o, ids: {6, 10}},
|
||||
|
||||
# Set 7: Trivia
|
||||
%{question: "MapleStory is developed by Nexon?", display: "O", answer: :o, ids: {7, 1}},
|
||||
%{question: "The Black Mage is the main antagonist?", display: "O", answer: :o, ids: {7, 2}},
|
||||
%{question: "Elvis is a monster in MapleStory?", display: "X", answer: :x, ids: {7, 3}},
|
||||
%{question: "Golems are made of rock?", display: "O", answer: :o, ids: {7, 4}},
|
||||
%{question: "Maple Island is shaped like a maple leaf?", display: "O", answer: :o, ids: {7, 5}},
|
||||
%{question: "All classes can fly?", display: "X", answer: :x, ids: {7, 6}},
|
||||
%{question: "The Moon Bunny is a boss?", display: "X", answer: :x, ids: {7, 7}},
|
||||
%{question: "Scissors of Karma make items tradable?", display: "O", answer: :o, ids: {7, 8}},
|
||||
%{question: "Monster Life is a farming minigame?", display: "O", answer: :o, ids: {7, 9}},
|
||||
%{question: "FM stands for Free Market?", display: "O", answer: :o, ids: {7, 10}}
|
||||
]
|
||||
end
|
||||
end
|
||||
437
lib/odinsea/game/events/snowball.ex
Normal file
437
lib/odinsea/game/events/snowball.ex
Normal file
@@ -0,0 +1,437 @@
|
||||
defmodule Odinsea.Game.Events.Snowball do
|
||||
@moduledoc """
|
||||
Snowball Event - Team-based snowball rolling competition.
|
||||
Ported from Java `server.events.MapleSnowball`.
|
||||
|
||||
## Gameplay
|
||||
- Two teams (Story and Maple) compete to roll snowballs
|
||||
- Players hit snowballs to move them forward
|
||||
- Snowmen block the opposing team's snowball
|
||||
- First team to reach position 899 wins
|
||||
|
||||
## Maps
|
||||
- Single map: 109060000
|
||||
|
||||
## Teams
|
||||
- Team 0 (Story): Bottom snowball, y > -80
|
||||
- Team 1 (Maple): Top snowball, y <= -80
|
||||
|
||||
## Win Condition
|
||||
- First team to push snowball past position 899 wins
|
||||
"""
|
||||
|
||||
alias Odinsea.Game.Event
|
||||
alias Odinsea.Game.Timer.EventTimer
|
||||
|
||||
require Logger
|
||||
|
||||
# ============================================================================
|
||||
# Types
|
||||
# ============================================================================
|
||||
|
||||
@typedoc "Snowball state struct"
|
||||
@type snowball :: %{
|
||||
team: 0 | 1,
|
||||
position: non_neg_integer(), # 0-899
|
||||
start_point: non_neg_integer(), # Stage progress
|
||||
invis: boolean(),
|
||||
hittable: boolean(),
|
||||
snowman_hp: non_neg_integer(),
|
||||
schedule: reference() | nil
|
||||
}
|
||||
|
||||
@typedoc "Snowball event state"
|
||||
@type t :: %__MODULE__{
|
||||
base: Event.t(),
|
||||
snowballs: %{0 => snowball(), 1 => snowball()},
|
||||
game_active: boolean()
|
||||
}
|
||||
|
||||
defstruct [
|
||||
:base,
|
||||
snowballs: %{},
|
||||
game_active: false
|
||||
]
|
||||
|
||||
# ============================================================================
|
||||
# Constants
|
||||
# ============================================================================
|
||||
|
||||
@map_ids [109060000]
|
||||
@finish_position 899
|
||||
@snowman_max_hp 7500
|
||||
@snowman_invincible_time 10_000 # 10 seconds
|
||||
|
||||
# Stage positions
|
||||
@stage_positions [255, 511, 767]
|
||||
|
||||
# Damage values
|
||||
@damage_normal 10
|
||||
@damage_snowman 15
|
||||
@damage_snowman_crit 45
|
||||
@damage_snowman_miss 0
|
||||
|
||||
# ============================================================================
|
||||
# Event Implementation
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Creates a new Snowball event for the given channel.
|
||||
"""
|
||||
def new(channel_id) do
|
||||
base = Event.new(:snowball, channel_id, @map_ids)
|
||||
|
||||
%__MODULE__{
|
||||
base: base,
|
||||
snowballs: %{},
|
||||
game_active: false
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the map IDs for this event type.
|
||||
"""
|
||||
def map_ids, do: @map_ids
|
||||
|
||||
@doc """
|
||||
Resets the event state for a new game.
|
||||
"""
|
||||
def reset(%__MODULE__{} = event) do
|
||||
base = %{event.base | is_running: true, player_count: 0}
|
||||
|
||||
# Initialize snowballs for both teams
|
||||
snowballs = %{
|
||||
0 => create_snowball(0),
|
||||
1 => create_snowball(1)
|
||||
}
|
||||
|
||||
%__MODULE__{
|
||||
event |
|
||||
base: base,
|
||||
snowballs: snowballs,
|
||||
game_active: false
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Cleans up the event after it ends.
|
||||
"""
|
||||
def unreset(%__MODULE__{} = event) do
|
||||
# Cancel all snowball schedules
|
||||
Enum.each(event.snowballs, fn {_, ball} ->
|
||||
if ball.schedule do
|
||||
EventTimer.cancel(ball.schedule)
|
||||
end
|
||||
end)
|
||||
|
||||
base = %{event.base | is_running: false, player_count: 0}
|
||||
|
||||
%__MODULE__{
|
||||
event |
|
||||
base: base,
|
||||
snowballs: %{},
|
||||
game_active: false
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Called when a player finishes.
|
||||
Snowball doesn't use this - winner determined by position.
|
||||
"""
|
||||
def finished(_event, _character) do
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Starts the snowball event gameplay.
|
||||
"""
|
||||
def start_event(%__MODULE__{} = event) do
|
||||
Logger.info("Starting Snowball event on channel #{event.base.channel_id}")
|
||||
|
||||
# Initialize snowballs
|
||||
snowballs = %{
|
||||
0 => %{create_snowball(0) | invis: false, hittable: true},
|
||||
1 => %{create_snowball(1) | invis: false, hittable: true}
|
||||
}
|
||||
|
||||
event = %{event | snowballs: snowballs, game_active: true}
|
||||
|
||||
# Broadcast start
|
||||
Event.broadcast_to_event(event.base, :event_started)
|
||||
Event.broadcast_to_event(event.base, :enter_snowball)
|
||||
|
||||
# Broadcast initial snowball state
|
||||
broadcast_snowball_update(event, 0)
|
||||
broadcast_snowball_update(event, 1)
|
||||
|
||||
event
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a snowball by team.
|
||||
"""
|
||||
def get_snowball(%__MODULE__{snowballs: balls}, team) when team in [0, 1] do
|
||||
Map.get(balls, team)
|
||||
end
|
||||
|
||||
def get_snowball(_, _), do: nil
|
||||
|
||||
@doc """
|
||||
Gets both snowballs.
|
||||
"""
|
||||
def get_all_snowballs(%__MODULE__{snowballs: balls}), do: balls
|
||||
|
||||
@doc """
|
||||
Handles a player hitting a snowball.
|
||||
|
||||
## Parameters
|
||||
- event: Snowball event state
|
||||
- character: The character hitting
|
||||
- position: Character position %{x, y}
|
||||
"""
|
||||
def hit_snowball(%__MODULE__{game_active: false}, _, _) do
|
||||
# Game not active
|
||||
:ok
|
||||
end
|
||||
|
||||
def hit_snowball(%__MODULE__{} = event, character, %{x: x, y: y}) do
|
||||
# Determine team based on Y position
|
||||
team = if y > -80, do: 0, else: 1
|
||||
ball = get_snowball(event, team)
|
||||
|
||||
if ball == nil or ball.invis do
|
||||
:ok
|
||||
else
|
||||
# Check if hitting snowman or snowball
|
||||
snowman = x < -360 and x > -560
|
||||
|
||||
if not snowman do
|
||||
# Hitting the snowball
|
||||
handle_snowball_hit(event, ball, character, x)
|
||||
else
|
||||
# Hitting the snowman
|
||||
handle_snowman_hit(event, ball, team)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Updates a snowball's position.
|
||||
"""
|
||||
def update_position(%__MODULE__{} = event, team, new_position) when team in [0, 1] do
|
||||
ball = get_snowball(event, team)
|
||||
|
||||
if ball do
|
||||
updated_ball = %{ball | position: new_position}
|
||||
snowballs = Map.put(event.snowballs, team, updated_ball)
|
||||
|
||||
# Check for stage transitions
|
||||
if new_position in @stage_positions do
|
||||
updated_ball = %{updated_ball | start_point: updated_ball.start_point + 1}
|
||||
broadcast_message(event, team, updated_ball.start_point)
|
||||
end
|
||||
|
||||
# Check for finish
|
||||
if new_position >= @finish_position do
|
||||
end_game(event, team)
|
||||
else
|
||||
broadcast_roll(event)
|
||||
%{event | snowballs: snowballs}
|
||||
end
|
||||
else
|
||||
event
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Sets a snowball's hittable state.
|
||||
"""
|
||||
def set_hittable(%__MODULE__{} = event, team, hittable) when team in [0, 1] do
|
||||
ball = get_snowball(event, team)
|
||||
|
||||
if ball do
|
||||
updated_ball = %{ball | hittable: hittable}
|
||||
snowballs = Map.put(event.snowballs, team, updated_ball)
|
||||
%{event | snowballs: snowballs}
|
||||
else
|
||||
event
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Sets a snowball's visibility.
|
||||
"""
|
||||
def set_invis(%__MODULE__{} = event, team, invis) when team in [0, 1] do
|
||||
ball = get_snowball(event, team)
|
||||
|
||||
if ball do
|
||||
updated_ball = %{ball | invis: invis}
|
||||
snowballs = Map.put(event.snowballs, team, updated_ball)
|
||||
%{event | snowballs: snowballs}
|
||||
else
|
||||
event
|
||||
end
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Private Functions
|
||||
# ============================================================================
|
||||
|
||||
defp create_snowball(team) do
|
||||
%{
|
||||
team: team,
|
||||
position: 0,
|
||||
start_point: 0,
|
||||
invis: true,
|
||||
hittable: true,
|
||||
snowman_hp: @snowman_max_hp,
|
||||
schedule: nil
|
||||
}
|
||||
end
|
||||
|
||||
defp handle_snowball_hit(event, ball, character, char_x) do
|
||||
# Calculate damage
|
||||
damage = calculate_snowball_damage(ball, char_x)
|
||||
|
||||
if damage > 0 and ball.hittable do
|
||||
# Move snowball
|
||||
new_position = ball.position + 1
|
||||
update_position(event, ball.team, new_position)
|
||||
else
|
||||
# Knockback chance (20%)
|
||||
if :rand.uniform() < 0.2 do
|
||||
# Send knockback packet
|
||||
send_knockback(character)
|
||||
end
|
||||
end
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
defp handle_snowman_hit(event, ball, team) do
|
||||
# Calculate damage
|
||||
roll = :rand.uniform()
|
||||
|
||||
damage = cond do
|
||||
roll < 0.05 -> @damage_snowman_crit # 5% crit
|
||||
roll < 0.35 -> @damage_snowman_miss # 30% miss
|
||||
true -> @damage_snowman # 65% normal
|
||||
end
|
||||
|
||||
if damage > 0 do
|
||||
new_hp = ball.snowman_hp - damage
|
||||
|
||||
if new_hp <= 0 do
|
||||
# Snowman destroyed - make enemy ball unhittable
|
||||
new_hp = @snowman_max_hp
|
||||
|
||||
enemy_team = if team == 0, do: 1, else: 0
|
||||
event = set_hittable(event, enemy_team, false)
|
||||
|
||||
# Broadcast message
|
||||
broadcast_message(event, enemy_team, 4)
|
||||
|
||||
# Schedule re-hittable
|
||||
schedule_ref = EventTimer.schedule(
|
||||
fn ->
|
||||
set_hittable(event, enemy_team, true)
|
||||
broadcast_message(event, enemy_team, 5)
|
||||
end,
|
||||
@snowman_invincible_time
|
||||
)
|
||||
|
||||
# Update ball with schedule
|
||||
enemy_ball = get_snowball(event, enemy_team)
|
||||
if enemy_ball do
|
||||
updated_enemy = %{enemy_ball | schedule: schedule_ref}
|
||||
snowballs = Map.put(event.snowballs, enemy_team, updated_enemy)
|
||||
event = %{event | snowballs: snowballs}
|
||||
end
|
||||
|
||||
# Apply seduce debuff to enemy team
|
||||
apply_seduce(event, enemy_team)
|
||||
end
|
||||
|
||||
# Update snowman HP
|
||||
updated_ball = %{ball | snowman_hp: new_hp}
|
||||
snowballs = Map.put(event.snowballs, team, updated_ball)
|
||||
%{event | snowballs: snowballs}
|
||||
end
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
defp calculate_snowball_damage(ball, char_x) do
|
||||
left_x = get_left_x(ball)
|
||||
right_x = get_right_x(ball)
|
||||
|
||||
# 1% chance for damage, or if in hit zone
|
||||
if :rand.uniform() < 0.01 or (char_x > left_x and char_x < right_x) do
|
||||
@damage_normal
|
||||
else
|
||||
0
|
||||
end
|
||||
end
|
||||
|
||||
defp get_left_x(%{position: pos}) do
|
||||
pos * 3 + 175
|
||||
end
|
||||
|
||||
defp get_right_x(ball) do
|
||||
get_left_x(ball) + 275
|
||||
end
|
||||
|
||||
defp broadcast_snowball_update(%__MODULE__{} = event, team) do
|
||||
ball = get_snowball(event, team)
|
||||
if ball do
|
||||
# Broadcast snowball state
|
||||
Event.broadcast_to_event(event.base, {:snowball_message, team, ball.start_point})
|
||||
end
|
||||
end
|
||||
|
||||
defp broadcast_message(%__MODULE__{} = event, team, message) do
|
||||
Event.broadcast_to_event(event.base, {:snowball_message, team, message})
|
||||
end
|
||||
|
||||
defp broadcast_roll(%__MODULE__{} = event) do
|
||||
ball0 = get_snowball(event, 0)
|
||||
ball1 = get_snowball(event, 1)
|
||||
|
||||
Event.broadcast_to_event(event.base, {:roll_snowball, ball0, ball1})
|
||||
end
|
||||
|
||||
defp send_knockback(_character) do
|
||||
# Send knockback packet to character
|
||||
:ok
|
||||
end
|
||||
|
||||
defp apply_seduce(_event, _team) do
|
||||
# Apply seduce debuff to enemy team
|
||||
# This would use MobSkillFactory to apply debuff
|
||||
:ok
|
||||
end
|
||||
|
||||
defp end_game(%__MODULE__{} = event, winner_team) do
|
||||
team_name = if winner_team == 0, do: "Story", else: "Maple"
|
||||
Logger.info("Snowball event ended! Team #{team_name} wins!")
|
||||
|
||||
# Make both snowballs invisible
|
||||
event = set_invis(event, 0, true)
|
||||
event = set_invis(event, 1, true)
|
||||
|
||||
# Broadcast winner
|
||||
Event.broadcast_to_event(
|
||||
event.base,
|
||||
{:server_notice, "Congratulations! Team #{team_name} has won the Snowball Event!"}
|
||||
)
|
||||
|
||||
# Give prizes to winners
|
||||
# In real implementation:
|
||||
# - Get all players on map
|
||||
# - Winners (based on Y position) get prize
|
||||
# - Everyone gets warped back
|
||||
|
||||
# Unreset event
|
||||
unreset(%{event | game_active: false})
|
||||
end
|
||||
end
|
||||
247
lib/odinsea/game/events/survival.ex
Normal file
247
lib/odinsea/game/events/survival.ex
Normal file
@@ -0,0 +1,247 @@
|
||||
defmodule Odinsea.Game.Events.Survival do
|
||||
@moduledoc """
|
||||
Survival Event - Last-man-standing platform challenge.
|
||||
Ported from Java `server.events.MapleSurvival`.
|
||||
|
||||
## Gameplay
|
||||
- Players must navigate platforms without falling
|
||||
- Fall once = elimination
|
||||
- Last players to survive win
|
||||
|
||||
## Maps
|
||||
- Stage 1: 809040000
|
||||
- Stage 2: 809040100
|
||||
|
||||
## Win Condition
|
||||
- Survive until time runs out
|
||||
- Last players standing win
|
||||
"""
|
||||
|
||||
alias Odinsea.Game.Event
|
||||
alias Odinsea.Game.Timer.EventTimer
|
||||
alias Odinsea.Game.Character
|
||||
|
||||
require Logger
|
||||
|
||||
# ============================================================================
|
||||
# Types
|
||||
# ============================================================================
|
||||
|
||||
@typedoc "Survival event state"
|
||||
@type t :: %__MODULE__{
|
||||
base: Event.t(),
|
||||
time_started: integer() | nil,
|
||||
event_duration: non_neg_integer(),
|
||||
schedules: [reference()]
|
||||
}
|
||||
|
||||
defstruct [
|
||||
:base,
|
||||
time_started: nil,
|
||||
event_duration: 360_000, # 6 minutes default
|
||||
schedules: []
|
||||
]
|
||||
|
||||
# ============================================================================
|
||||
# Constants
|
||||
# ============================================================================
|
||||
|
||||
@map_ids [809040000, 809040100]
|
||||
@default_duration 360_000 # 6 minutes in ms
|
||||
|
||||
# ============================================================================
|
||||
# Event Implementation
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Creates a new Survival event for the given channel.
|
||||
"""
|
||||
def new(channel_id) do
|
||||
base = Event.new(:survival, channel_id, @map_ids)
|
||||
|
||||
%__MODULE__{
|
||||
base: base,
|
||||
time_started: nil,
|
||||
event_duration: @default_duration,
|
||||
schedules: []
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the map IDs for this event type.
|
||||
"""
|
||||
def map_ids, do: @map_ids
|
||||
|
||||
@doc """
|
||||
Resets the event state for a new game.
|
||||
"""
|
||||
def reset(%__MODULE__{} = event) do
|
||||
# Cancel existing schedules
|
||||
cancel_schedules(event)
|
||||
|
||||
# Close entry portal
|
||||
set_portal_state(event, "join00", false)
|
||||
|
||||
base = %{event.base | is_running: true, player_count: 0}
|
||||
|
||||
%__MODULE__{
|
||||
event |
|
||||
base: base,
|
||||
time_started: nil,
|
||||
schedules: []
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Cleans up the event after it ends.
|
||||
"""
|
||||
def unreset(%__MODULE__{} = event) do
|
||||
cancel_schedules(event)
|
||||
|
||||
# Open entry portal
|
||||
set_portal_state(event, "join00", true)
|
||||
|
||||
base = %{event.base | is_running: false, player_count: 0}
|
||||
|
||||
%__MODULE__{
|
||||
event |
|
||||
base: base,
|
||||
time_started: nil,
|
||||
schedules: []
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Called when a player finishes (reaches end map).
|
||||
Gives prize and achievement.
|
||||
"""
|
||||
def finished(%__MODULE__{} = event, character) do
|
||||
Logger.info("Player #{character.name} finished Survival event!")
|
||||
|
||||
# Give prize
|
||||
Event.give_prize(character)
|
||||
|
||||
# Give achievement (ID 25)
|
||||
Character.finish_achievement(character, 25)
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Called when a player loads into an event map.
|
||||
Sends clock if timer is running.
|
||||
"""
|
||||
def on_map_load(%__MODULE__{} = event, character) do
|
||||
if is_timer_started(event) do
|
||||
time_left = get_time_left(event)
|
||||
Logger.debug("Sending Survival clock to #{character.name}: #{div(time_left, 1000)}s remaining")
|
||||
# Send clock packet
|
||||
end
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Starts the Survival event gameplay.
|
||||
"""
|
||||
def start_event(%__MODULE__{} = event) do
|
||||
Logger.info("Starting Survival event on channel #{event.base.channel_id}")
|
||||
|
||||
now = System.system_time(:millisecond)
|
||||
|
||||
# Close entry portal
|
||||
set_portal_state(event, "join00", false)
|
||||
|
||||
# Broadcast start
|
||||
Event.broadcast_to_event(event.base, :event_started)
|
||||
Event.broadcast_to_event(event.base, {:clock, div(event.event_duration, 1000)})
|
||||
Event.broadcast_to_event(event.base, {:server_notice, "The portal has now opened. Press the up arrow key at the portal to enter."})
|
||||
Event.broadcast_to_event(event.base, {:server_notice, "Fall down once, and never get back up again! Get to the top without falling down!"})
|
||||
|
||||
# Schedule event end
|
||||
end_ref = EventTimer.schedule(
|
||||
fn -> end_event(event) end,
|
||||
event.event_duration
|
||||
)
|
||||
|
||||
%__MODULE__{
|
||||
event |
|
||||
time_started: now,
|
||||
schedules: [end_ref]
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if the timer has started.
|
||||
"""
|
||||
def is_timer_started(%__MODULE__{time_started: nil}), do: false
|
||||
def is_timer_started(%__MODULE__{}), do: true
|
||||
|
||||
@doc """
|
||||
Gets the total event duration in milliseconds.
|
||||
"""
|
||||
def get_time(%__MODULE__{event_duration: duration}), do: duration
|
||||
|
||||
@doc """
|
||||
Gets the time remaining in milliseconds.
|
||||
"""
|
||||
def get_time_left(%__MODULE__{time_started: nil, event_duration: duration}), do: duration
|
||||
def get_time_left(%__MODULE__{time_started: started, event_duration: duration}) do
|
||||
elapsed = System.system_time(:millisecond) - started
|
||||
max(0, duration - elapsed)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Handles a player falling (elimination).
|
||||
"""
|
||||
def player_fell(%__MODULE__{} = event, character) do
|
||||
Logger.info("Player #{character.name} fell and was eliminated from Survival event")
|
||||
|
||||
# Warp player out
|
||||
Event.warp_back(character)
|
||||
|
||||
# Unregister from event
|
||||
base = Event.unregister_player(event.base, character.id)
|
||||
|
||||
%{event | base: base}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if a player position is valid (on platform).
|
||||
Falling below a certain Y coordinate = elimination.
|
||||
"""
|
||||
def valid_position?(%__MODULE__{}, %{y: y}) do
|
||||
# Y threshold for falling (map-specific)
|
||||
y > -500 # Example threshold
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Private Functions
|
||||
# ============================================================================
|
||||
|
||||
defp cancel_schedules(%__MODULE__{schedules: schedules} = event) do
|
||||
Enum.each(schedules, fn ref ->
|
||||
EventTimer.cancel(ref)
|
||||
end)
|
||||
|
||||
%{event | schedules: []}
|
||||
end
|
||||
|
||||
defp set_portal_state(%__MODULE__{}, _portal_name, _state) do
|
||||
# In real implementation, update map portal state
|
||||
:ok
|
||||
end
|
||||
|
||||
defp end_event(%__MODULE__{} = event) do
|
||||
Logger.info("Survival event ended on channel #{event.base.channel_id}")
|
||||
|
||||
# Warp out all remaining players
|
||||
# In real implementation:
|
||||
# - Get all players on event maps
|
||||
# - Give prizes to survivors
|
||||
# - Warp each back to saved location
|
||||
|
||||
# Unreset event
|
||||
unreset(event)
|
||||
end
|
||||
end
|
||||
599
lib/odinsea/game/hired_merchant.ex
Normal file
599
lib/odinsea/game/hired_merchant.ex
Normal file
@@ -0,0 +1,599 @@
|
||||
defmodule Odinsea.Game.HiredMerchant do
|
||||
@moduledoc """
|
||||
Hired Merchant (permanent NPC shop) system.
|
||||
Ported from src/server/shops/HiredMerchant.java
|
||||
|
||||
Hired Merchants are permanent shops that:
|
||||
- Stay open even when the owner is offline
|
||||
- Can be placed in the Free Market
|
||||
- Support visitor browsing and buying
|
||||
- Have a blacklist system
|
||||
- Can save items to Fredrick when closed
|
||||
|
||||
Shop lifecycle:
|
||||
1. Owner uses hired merchant item
|
||||
2. Shop is created and items are added
|
||||
3. Shop stays open for extended period (or until owner closes it)
|
||||
4. When closed, unsold items and mesos can be retrieved from Fredrick
|
||||
"""
|
||||
|
||||
use GenServer
|
||||
require Logger
|
||||
|
||||
alias Odinsea.Game.{ShopItem, Item, Equip}
|
||||
|
||||
# Shop type constant
|
||||
@shop_type 1
|
||||
|
||||
# Maximum visitors
|
||||
@max_visitors 3
|
||||
|
||||
# Hired merchant duration (24 hours in milliseconds)
|
||||
@merchant_duration 24 * 60 * 60 * 1000
|
||||
|
||||
# Struct for the merchant state
|
||||
defstruct [
|
||||
:id,
|
||||
:owner_id,
|
||||
:owner_account_id,
|
||||
:owner_name,
|
||||
:item_id,
|
||||
:description,
|
||||
:map_id,
|
||||
:channel,
|
||||
:position,
|
||||
:store_id,
|
||||
:meso,
|
||||
:items,
|
||||
:visitors,
|
||||
:visitor_names,
|
||||
:blacklist,
|
||||
:open,
|
||||
:available,
|
||||
:bought_items,
|
||||
:start_time
|
||||
]
|
||||
|
||||
@doc """
|
||||
Starts a new hired merchant GenServer.
|
||||
"""
|
||||
def start_link(opts) do
|
||||
merchant_id = Keyword.fetch!(opts, :id)
|
||||
GenServer.start_link(__MODULE__, opts, name: via_tuple(merchant_id))
|
||||
end
|
||||
|
||||
@doc """
|
||||
Creates a new hired merchant.
|
||||
"""
|
||||
def create(opts) do
|
||||
%__MODULE__{
|
||||
id: opts[:id] || generate_id(),
|
||||
owner_id: opts[:owner_id],
|
||||
owner_account_id: opts[:owner_account_id],
|
||||
owner_name: opts[:owner_name],
|
||||
item_id: opts[:item_id],
|
||||
description: opts[:description] || "",
|
||||
map_id: opts[:map_id],
|
||||
channel: opts[:channel],
|
||||
position: opts[:position],
|
||||
store_id: 0,
|
||||
meso: 0,
|
||||
items: [],
|
||||
visitors: %{},
|
||||
visitor_names: [],
|
||||
blacklist: [],
|
||||
open: false,
|
||||
available: false,
|
||||
bought_items: [],
|
||||
start_time: System.system_time(:millisecond)
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the shop type (1 = hired merchant).
|
||||
"""
|
||||
def shop_type, do: @shop_type
|
||||
|
||||
@doc """
|
||||
Gets the current merchant state.
|
||||
"""
|
||||
def get_state(merchant_pid) when is_pid(merchant_pid) do
|
||||
GenServer.call(merchant_pid, :get_state)
|
||||
end
|
||||
|
||||
def get_state(merchant_id) do
|
||||
case lookup(merchant_id) do
|
||||
{:ok, pid} -> get_state(pid)
|
||||
error -> error
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Looks up a merchant by ID.
|
||||
"""
|
||||
def lookup(merchant_id) do
|
||||
case Registry.lookup(Odinsea.MerchantRegistry, merchant_id) do
|
||||
[{pid, _}] -> {:ok, pid}
|
||||
[] -> {:error, :not_found}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Adds an item to the merchant.
|
||||
"""
|
||||
def add_item(merchant_id, %ShopItem{} = item) do
|
||||
with {:ok, pid} <- lookup(merchant_id) do
|
||||
GenServer.call(pid, {:add_item, item})
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Buys an item from the merchant.
|
||||
Returns {:ok, item, price} on success or {:error, reason} on failure.
|
||||
"""
|
||||
def buy_item(merchant_id, slot, quantity, buyer_id, buyer_name) do
|
||||
with {:ok, pid} <- lookup(merchant_id) do
|
||||
GenServer.call(pid, {:buy_item, slot, quantity, buyer_id, buyer_name})
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Searches for items by item ID in the merchant.
|
||||
"""
|
||||
def search_item(merchant_id, item_id) do
|
||||
with {:ok, pid} <- lookup(merchant_id) do
|
||||
GenServer.call(pid, {:search_item, item_id})
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Adds a visitor to the merchant.
|
||||
Returns the visitor slot (1-3) or {:error, :full/:blacklisted}.
|
||||
"""
|
||||
def add_visitor(merchant_id, character_id, character_name, character_pid) do
|
||||
with {:ok, pid} <- lookup(merchant_id) do
|
||||
GenServer.call(pid, {:add_visitor, character_id, character_name, character_pid})
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Removes a visitor from the merchant.
|
||||
"""
|
||||
def remove_visitor(merchant_id, character_id) do
|
||||
with {:ok, pid} <- lookup(merchant_id) do
|
||||
GenServer.call(pid, {:remove_visitor, character_id})
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Sets the merchant open status.
|
||||
"""
|
||||
def set_open(merchant_id, open) do
|
||||
with {:ok, pid} <- lookup(merchant_id) do
|
||||
GenServer.call(pid, {:set_open, open})
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Sets the merchant available status (visible on map).
|
||||
"""
|
||||
def set_available(merchant_id, available) do
|
||||
with {:ok, pid} <- lookup(merchant_id) do
|
||||
GenServer.call(pid, {:set_available, available})
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Sets the store ID (when registered with channel).
|
||||
"""
|
||||
def set_store_id(merchant_id, store_id) do
|
||||
with {:ok, pid} <- lookup(merchant_id) do
|
||||
GenServer.call(pid, {:set_store_id, store_id})
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Adds a player to the blacklist.
|
||||
"""
|
||||
def add_to_blacklist(merchant_id, character_name) do
|
||||
with {:ok, pid} <- lookup(merchant_id) do
|
||||
GenServer.call(pid, {:add_blacklist, character_name})
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Removes a player from the blacklist.
|
||||
"""
|
||||
def remove_from_blacklist(merchant_id, character_name) do
|
||||
with {:ok, pid} <- lookup(merchant_id) do
|
||||
GenServer.call(pid, {:remove_blacklist, character_name})
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if a player is in the blacklist.
|
||||
"""
|
||||
def is_blacklisted?(merchant_id, character_name) do
|
||||
with {:ok, pid} <- lookup(merchant_id) do
|
||||
GenServer.call(pid, {:is_blacklisted, character_name})
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the visitor list (for owner view).
|
||||
"""
|
||||
def get_visitors(merchant_id) do
|
||||
with {:ok, pid} <- lookup(merchant_id) do
|
||||
GenServer.call(pid, :get_visitors)
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the blacklist.
|
||||
"""
|
||||
def get_blacklist(merchant_id) do
|
||||
with {:ok, pid} <- lookup(merchant_id) do
|
||||
GenServer.call(pid, :get_blacklist)
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets time remaining for the merchant (in seconds).
|
||||
"""
|
||||
def get_time_remaining(merchant_id) do
|
||||
with {:ok, pid} <- lookup(merchant_id) do
|
||||
GenServer.call(pid, :get_time_remaining)
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the current meso amount.
|
||||
"""
|
||||
def get_meso(merchant_id) do
|
||||
with {:ok, pid} <- lookup(merchant_id) do
|
||||
GenServer.call(pid, :get_meso)
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Sets the meso amount.
|
||||
"""
|
||||
def set_meso(merchant_id, meso) do
|
||||
with {:ok, pid} <- lookup(merchant_id) do
|
||||
GenServer.call(pid, {:set_meso, meso})
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Closes the merchant and saves items.
|
||||
"""
|
||||
def close_merchant(merchant_id, save_items \\ true, remove \\ true) do
|
||||
with {:ok, pid} <- lookup(merchant_id) do
|
||||
GenServer.call(pid, {:close_merchant, save_items, remove})
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if a character is the owner.
|
||||
"""
|
||||
def is_owner?(merchant_id, character_id) do
|
||||
with {:ok, pid} <- lookup(merchant_id) do
|
||||
GenServer.call(pid, {:is_owner, character_id})
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Broadcasts a packet to all visitors.
|
||||
"""
|
||||
def broadcast_to_visitors(merchant_id, packet) do
|
||||
with {:ok, pid} <- lookup(merchant_id) do
|
||||
GenServer.cast(pid, {:broadcast, packet})
|
||||
end
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# GenServer Callbacks
|
||||
# ============================================================================
|
||||
|
||||
@impl true
|
||||
def init(opts) do
|
||||
state = create(opts)
|
||||
# Schedule expiration check
|
||||
schedule_expiration_check()
|
||||
{:ok, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call(:get_state, _from, state) do
|
||||
{:reply, state, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:add_item, item}, _from, state) do
|
||||
new_items = state.items ++ [item]
|
||||
{:reply, :ok, %{state | items: new_items}}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:buy_item, slot, quantity, _buyer_id, buyer_name}, _from, state) do
|
||||
cond do
|
||||
slot < 0 or slot >= length(state.items) ->
|
||||
{:reply, {:error, :invalid_slot}, state}
|
||||
|
||||
true ->
|
||||
shop_item = Enum.at(state.items, slot)
|
||||
|
||||
cond do
|
||||
shop_item.bundles < quantity ->
|
||||
{:reply, {:error, :not_enough_stock}, state}
|
||||
|
||||
true ->
|
||||
price = shop_item.price * quantity
|
||||
|
||||
# Calculate tax (EntrustedStoreTax)
|
||||
tax = calculate_tax(price)
|
||||
net_price = price - tax
|
||||
|
||||
# Create bought item record
|
||||
bought_record = %{
|
||||
item_id: shop_item.item.item_id,
|
||||
quantity: quantity,
|
||||
total_price: price,
|
||||
buyer: buyer_name
|
||||
}
|
||||
|
||||
# Reduce bundles
|
||||
updated_item = ShopItem.reduce_bundles(shop_item, quantity)
|
||||
|
||||
# Update items list
|
||||
new_items =
|
||||
if ShopItem.sold_out?(updated_item) do
|
||||
List.delete_at(state.items, slot)
|
||||
else
|
||||
List.replace_at(state.items, slot, updated_item)
|
||||
end
|
||||
|
||||
# Create item for buyer
|
||||
buyer_item = ShopItem.create_buyer_item(shop_item, quantity)
|
||||
|
||||
# Update meso
|
||||
new_meso = state.meso + net_price
|
||||
|
||||
# Update state
|
||||
new_bought_items = [bought_record | state.bought_items]
|
||||
|
||||
new_state = %{
|
||||
state
|
||||
| items: new_items,
|
||||
meso: new_meso,
|
||||
bought_items: new_bought_items
|
||||
}
|
||||
|
||||
# Notify owner if online (simplified - would need world lookup)
|
||||
# Logger.info("Merchant item sold: #{shop_item.item.item_id} to #{buyer_name}")
|
||||
|
||||
{:reply, {:ok, buyer_item, price}, new_state}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:search_item, item_id}, _from, state) do
|
||||
results =
|
||||
Enum.filter(state.items, fn shop_item ->
|
||||
shop_item.item.item_id == item_id and shop_item.bundles > 0
|
||||
end)
|
||||
|
||||
{:reply, results, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:add_visitor, character_id, character_name, character_pid}, _from, state) do
|
||||
# Check blacklist
|
||||
if character_name in state.blacklist do
|
||||
{:reply, {:error, :blacklisted}, state}
|
||||
else
|
||||
# Check if already visiting
|
||||
if Map.has_key?(state.visitors, character_id) do
|
||||
slot = get_slot_for_character(state, character_id)
|
||||
{:reply, {:ok, slot}, state}
|
||||
else
|
||||
# Find free slot
|
||||
case find_free_slot(state) do
|
||||
nil ->
|
||||
{:reply, {:error, :full}, state}
|
||||
|
||||
slot ->
|
||||
new_visitors =
|
||||
Map.put(state.visitors, character_id, %{
|
||||
pid: character_pid,
|
||||
slot: slot,
|
||||
name: character_name
|
||||
})
|
||||
|
||||
# Track visitor name for history
|
||||
new_visitor_names =
|
||||
if character_id != state.owner_id do
|
||||
[character_name | state.visitor_names]
|
||||
else
|
||||
state.visitor_names
|
||||
end
|
||||
|
||||
new_state = %{
|
||||
state
|
||||
| visitors: new_visitors,
|
||||
visitor_names: new_visitor_names
|
||||
}
|
||||
|
||||
{:reply, {:ok, slot}, new_state}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:remove_visitor, character_id}, _from, state) do
|
||||
new_visitors = Map.delete(state.visitors, character_id)
|
||||
{:reply, :ok, %{state | visitors: new_visitors}}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:set_open, open}, _from, state) do
|
||||
{:reply, :ok, %{state | open: open}}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:set_available, available}, _from, state) do
|
||||
{:reply, :ok, %{state | available: available}}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:set_store_id, store_id}, _from, state) do
|
||||
{:reply, :ok, %{state | store_id: store_id}}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:add_blacklist, character_name}, _from, state) do
|
||||
new_blacklist =
|
||||
if character_name in state.blacklist do
|
||||
state.blacklist
|
||||
else
|
||||
[character_name | state.blacklist]
|
||||
end
|
||||
|
||||
{:reply, :ok, %{state | blacklist: new_blacklist}}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:remove_blacklist, character_name}, _from, state) do
|
||||
new_blacklist = List.delete(state.blacklist, character_name)
|
||||
{:reply, :ok, %{state | blacklist: new_blacklist}}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:is_blacklisted, character_name}, _from, state) do
|
||||
{:reply, character_name in state.blacklist, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call(:get_visitors, _from, state) do
|
||||
visitor_list = Enum.map(state.visitors, fn {_id, data} -> data.name end)
|
||||
{:reply, visitor_list, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call(:get_blacklist, _from, state) do
|
||||
{:reply, state.blacklist, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call(:get_time_remaining, _from, state) do
|
||||
elapsed = System.system_time(:millisecond) - state.start_time
|
||||
remaining = max(0, div(@merchant_duration - elapsed, 1000))
|
||||
{:reply, remaining, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call(:get_meso, _from, state) do
|
||||
{:reply, state.meso, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:set_meso, meso}, _from, state) do
|
||||
{:reply, :ok, %{state | meso: meso}}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:close_merchant, save_items, _remove}, _from, state) do
|
||||
# Remove all visitors
|
||||
Enum.each(state.visitors, fn {_id, data} ->
|
||||
send(data.pid, {:merchant_closed, state.id})
|
||||
end)
|
||||
|
||||
# Prepare items for saving (to Fredrick)
|
||||
items_to_save =
|
||||
if save_items do
|
||||
Enum.filter(state.items, fn item -> item.bundles > 0 end)
|
||||
|> Enum.map(fn shop_item ->
|
||||
item = shop_item.item
|
||||
total_qty = shop_item.bundles * item.quantity
|
||||
%{item | quantity: total_qty}
|
||||
end)
|
||||
else
|
||||
[]
|
||||
end
|
||||
|
||||
# Return unsold items and meso to owner
|
||||
{:reply, {:ok, items_to_save, state.meso}, %{state | open: false, available: false}}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:is_owner, character_id}, _from, state) do
|
||||
{:reply, character_id == state.owner_id, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_cast({:broadcast, packet}, state) do
|
||||
Enum.each(state.visitors, fn {_id, data} ->
|
||||
send(data.pid, {:merchant_packet, packet})
|
||||
end)
|
||||
|
||||
{:noreply, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info(:check_expiration, state) do
|
||||
elapsed = System.system_time(:millisecond) - state.start_time
|
||||
|
||||
if elapsed >= @merchant_duration do
|
||||
# Merchant has expired - close it
|
||||
Logger.info("Hired merchant #{state.id} has expired")
|
||||
|
||||
# Notify owner and save items
|
||||
# In full implementation, this would send to Fredrick
|
||||
|
||||
{:stop, :normal, state}
|
||||
else
|
||||
schedule_expiration_check()
|
||||
{:noreply, state}
|
||||
end
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Private Helper Functions
|
||||
# ============================================================================
|
||||
|
||||
defp via_tuple(merchant_id) do
|
||||
{:via, Registry, {Odinsea.MerchantRegistry, merchant_id}}
|
||||
end
|
||||
|
||||
defp generate_id do
|
||||
:erlang.unique_integer([:positive])
|
||||
end
|
||||
|
||||
defp find_free_slot(state) do
|
||||
used_slots = Map.values(state.visitors) |> Enum.map(& &1.slot)
|
||||
|
||||
Enum.find(1..@max_visitors, fn slot ->
|
||||
slot not in used_slots
|
||||
end)
|
||||
end
|
||||
|
||||
defp get_slot_for_character(state, character_id) do
|
||||
case Map.get(state.visitors, character_id) do
|
||||
nil -> -1
|
||||
data -> data.slot
|
||||
end
|
||||
end
|
||||
|
||||
defp calculate_tax(amount) do
|
||||
# Simple tax calculation - can be made more complex
|
||||
# Based on GameConstants.EntrustedStoreTax
|
||||
div(amount, 10)
|
||||
end
|
||||
|
||||
defp schedule_expiration_check do
|
||||
# Check every hour
|
||||
Process.send_after(self(), :check_expiration, 60 * 60 * 1000)
|
||||
end
|
||||
end
|
||||
@@ -16,12 +16,55 @@ defmodule Odinsea.Game.Map do
|
||||
require Logger
|
||||
|
||||
alias Odinsea.Game.Character
|
||||
alias Odinsea.Game.{MapFactory, LifeFactory, Monster, Reactor, ReactorFactory}
|
||||
alias Odinsea.Channel.Packets, as: ChannelPackets
|
||||
|
||||
# ============================================================================
|
||||
# Data Structures
|
||||
# ============================================================================
|
||||
|
||||
defmodule SpawnPoint do
|
||||
@moduledoc "Represents a monster spawn point on the map"
|
||||
defstruct [
|
||||
:id,
|
||||
# Unique spawn point ID
|
||||
:mob_id,
|
||||
# Monster ID to spawn
|
||||
:x,
|
||||
# Spawn position X
|
||||
:y,
|
||||
# Spawn position Y
|
||||
:fh,
|
||||
# Foothold
|
||||
:cy,
|
||||
# CY value
|
||||
:f,
|
||||
# Facing direction (0 = left, 1 = right)
|
||||
:mob_time,
|
||||
# Respawn time in milliseconds
|
||||
:spawned_oid,
|
||||
# OID of currently spawned monster (nil if not spawned)
|
||||
:last_spawn_time,
|
||||
# Last time monster was spawned
|
||||
:respawn_timer_ref
|
||||
# Timer reference for respawn
|
||||
]
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
id: integer(),
|
||||
mob_id: integer(),
|
||||
x: integer(),
|
||||
y: integer(),
|
||||
fh: integer(),
|
||||
cy: integer(),
|
||||
f: integer(),
|
||||
mob_time: integer(),
|
||||
spawned_oid: integer() | nil,
|
||||
last_spawn_time: DateTime.t() | nil,
|
||||
respawn_timer_ref: reference() | nil
|
||||
}
|
||||
end
|
||||
|
||||
defmodule State do
|
||||
@moduledoc "Map instance state"
|
||||
defstruct [
|
||||
@@ -32,22 +75,26 @@ defmodule Odinsea.Game.Map do
|
||||
:players,
|
||||
# Map stores character_id => %{oid: integer(), character: Character.State}
|
||||
:monsters,
|
||||
# Map stores oid => Monster
|
||||
# Map stores oid => Monster.t()
|
||||
:npcs,
|
||||
# Map stores oid => NPC
|
||||
:items,
|
||||
# Map stores oid => Item
|
||||
:reactors,
|
||||
# Map stores oid => Reactor
|
||||
:spawn_points,
|
||||
# Map stores spawn_id => SpawnPoint.t()
|
||||
# Object ID counter
|
||||
:next_oid,
|
||||
# Map properties (TODO: load from WZ data)
|
||||
# Map properties (loaded from MapFactory)
|
||||
:return_map,
|
||||
:forced_return,
|
||||
:time_limit,
|
||||
:field_limit,
|
||||
:mob_rate,
|
||||
:drop_rate,
|
||||
:map_name,
|
||||
:street_name,
|
||||
# Timestamps
|
||||
:created_at
|
||||
]
|
||||
@@ -56,10 +103,11 @@ defmodule Odinsea.Game.Map do
|
||||
map_id: non_neg_integer(),
|
||||
channel_id: byte(),
|
||||
players: %{pos_integer() => map()},
|
||||
monsters: %{pos_integer() => any()},
|
||||
monsters: %{pos_integer() => Monster.t()},
|
||||
npcs: %{pos_integer() => any()},
|
||||
items: %{pos_integer() => any()},
|
||||
reactors: %{pos_integer() => any()},
|
||||
spawn_points: %{integer() => SpawnPoint.t()},
|
||||
next_oid: pos_integer(),
|
||||
return_map: non_neg_integer() | nil,
|
||||
forced_return: non_neg_integer() | nil,
|
||||
@@ -67,6 +115,8 @@ defmodule Odinsea.Game.Map do
|
||||
field_limit: non_neg_integer() | nil,
|
||||
mob_rate: float(),
|
||||
drop_rate: float(),
|
||||
map_name: String.t() | nil,
|
||||
street_name: String.t() | nil,
|
||||
created_at: DateTime.t()
|
||||
}
|
||||
end
|
||||
@@ -152,6 +202,69 @@ defmodule Odinsea.Game.Map do
|
||||
GenServer.call(via_tuple(map_id, channel_id), :get_players)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets all monsters on the map.
|
||||
"""
|
||||
def get_monsters(map_id, channel_id) do
|
||||
GenServer.call(via_tuple(map_id, channel_id), :get_monsters)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Spawns a monster at the specified spawn point.
|
||||
"""
|
||||
def spawn_monster(map_id, channel_id, spawn_id) do
|
||||
GenServer.cast(via_tuple(map_id, channel_id), {:spawn_monster, spawn_id})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Handles monster death and initiates respawn.
|
||||
"""
|
||||
def monster_killed(map_id, channel_id, oid, killer_id \\ nil) do
|
||||
GenServer.cast(via_tuple(map_id, channel_id), {:monster_killed, oid, killer_id})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Damages a monster.
|
||||
"""
|
||||
def damage_monster(map_id, channel_id, oid, damage, character_id) do
|
||||
GenServer.call(via_tuple(map_id, channel_id), {:damage_monster, oid, damage, character_id})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Hits a reactor, advancing its state and triggering effects.
|
||||
"""
|
||||
def hit_reactor(map_id, channel_id, oid, character_id, stance \\ 0) do
|
||||
GenServer.call(via_tuple(map_id, channel_id), {:hit_reactor, oid, character_id, stance})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Destroys a reactor (e.g., after final state).
|
||||
"""
|
||||
def destroy_reactor(map_id, channel_id, oid) do
|
||||
GenServer.call(via_tuple(map_id, channel_id), {:destroy_reactor, oid})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a reactor by OID.
|
||||
"""
|
||||
def get_reactor(map_id, channel_id, oid) do
|
||||
GenServer.call(via_tuple(map_id, channel_id), {:get_reactor, oid})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets all reactors on the map.
|
||||
"""
|
||||
def get_reactors(map_id, channel_id) do
|
||||
GenServer.call(via_tuple(map_id, channel_id), :get_reactors)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Respawns a destroyed reactor after its delay.
|
||||
"""
|
||||
def respawn_reactor(map_id, channel_id, original_reactor) do
|
||||
GenServer.cast(via_tuple(map_id, channel_id), {:respawn_reactor, original_reactor})
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# GenServer Callbacks
|
||||
# ============================================================================
|
||||
@@ -161,6 +274,26 @@ defmodule Odinsea.Game.Map do
|
||||
map_id = Keyword.fetch!(opts, :map_id)
|
||||
channel_id = Keyword.fetch!(opts, :channel_id)
|
||||
|
||||
# Load map template from MapFactory
|
||||
template = MapFactory.get_template(map_id)
|
||||
|
||||
spawn_points =
|
||||
if template do
|
||||
load_spawn_points(template)
|
||||
else
|
||||
%{}
|
||||
end
|
||||
|
||||
# Load reactor spawns from template and create reactors
|
||||
reactors =
|
||||
if template do
|
||||
load_reactors(template, 500_000)
|
||||
else
|
||||
{%{}, 500_000}
|
||||
end
|
||||
|
||||
{reactor_map, next_oid} = reactors
|
||||
|
||||
state = %State{
|
||||
map_id: map_id,
|
||||
channel_id: channel_id,
|
||||
@@ -168,18 +301,27 @@ defmodule Odinsea.Game.Map do
|
||||
monsters: %{},
|
||||
npcs: %{},
|
||||
items: %{},
|
||||
reactors: %{},
|
||||
next_oid: 500_000,
|
||||
return_map: nil,
|
||||
forced_return: nil,
|
||||
time_limit: nil,
|
||||
field_limit: 0,
|
||||
mob_rate: 1.0,
|
||||
reactors: reactor_map,
|
||||
spawn_points: spawn_points,
|
||||
next_oid: next_oid,
|
||||
return_map: if(template, do: template.return_map, else: nil),
|
||||
forced_return: if(template, do: template.forced_return, else: nil),
|
||||
time_limit: if(template, do: template.time_limit, else: nil),
|
||||
field_limit: if(template, do: template.field_limit, else: 0),
|
||||
mob_rate: if(template, do: template.mob_rate, else: 1.0),
|
||||
drop_rate: 1.0,
|
||||
map_name: if(template, do: template.map_name, else: "Unknown"),
|
||||
street_name: if(template, do: template.street_name, else: ""),
|
||||
created_at: DateTime.utc_now()
|
||||
}
|
||||
|
||||
Logger.debug("Map loaded: #{map_id} (channel #{channel_id})")
|
||||
Logger.debug("Map loaded: #{map_id} (channel #{channel_id}) - #{map_size(spawn_points)} spawn points")
|
||||
|
||||
# Schedule initial monster spawning
|
||||
if map_size(spawn_points) > 0 do
|
||||
Process.send_after(self(), :spawn_initial_monsters, 100)
|
||||
end
|
||||
|
||||
{:ok, state}
|
||||
end
|
||||
|
||||
@@ -208,6 +350,8 @@ defmodule Odinsea.Game.Map do
|
||||
|
||||
if client_pid do
|
||||
send_existing_players(client_pid, new_players, except: character_id)
|
||||
send_existing_monsters(client_pid, state.monsters)
|
||||
send_existing_reactors(client_pid, state.reactors)
|
||||
end
|
||||
|
||||
new_state = %{
|
||||
@@ -247,6 +391,79 @@ defmodule Odinsea.Game.Map do
|
||||
{:reply, state.players, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call(:get_monsters, _from, state) do
|
||||
{:reply, state.monsters, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:damage_monster, oid, damage_amount, character_id}, _from, state) do
|
||||
case Map.get(state.monsters, oid) do
|
||||
nil ->
|
||||
{:reply, {:error, :monster_not_found}, state}
|
||||
|
||||
monster ->
|
||||
# Apply damage to monster
|
||||
case Monster.damage(monster, damage_amount, character_id) do
|
||||
{:dead, updated_monster, actual_damage} ->
|
||||
# Monster died
|
||||
Logger.debug("Monster #{oid} killed on map #{state.map_id}")
|
||||
|
||||
# Remove monster from map
|
||||
new_monsters = Map.delete(state.monsters, oid)
|
||||
|
||||
# Find spawn point
|
||||
spawn_point_id = find_spawn_point_by_oid(state.spawn_points, oid)
|
||||
|
||||
# Update spawn point to clear spawned monster
|
||||
new_spawn_points =
|
||||
if spawn_point_id do
|
||||
update_spawn_point(state.spawn_points, spawn_point_id, fn sp ->
|
||||
%{sp | spawned_oid: nil, last_spawn_time: DateTime.utc_now()}
|
||||
end)
|
||||
else
|
||||
state.spawn_points
|
||||
end
|
||||
|
||||
# Schedule respawn
|
||||
if spawn_point_id do
|
||||
spawn_point = Map.get(new_spawn_points, spawn_point_id)
|
||||
schedule_respawn(spawn_point_id, spawn_point.mob_time)
|
||||
end
|
||||
|
||||
# Broadcast monster death packet
|
||||
kill_packet = ChannelPackets.kill_monster(updated_monster, 1)
|
||||
broadcast_to_players(state.players, kill_packet)
|
||||
|
||||
Logger.debug("Monster killed: OID #{oid} on map #{state.map_id}")
|
||||
|
||||
# Calculate and distribute EXP
|
||||
distribute_exp(updated_monster, state.players, character_id)
|
||||
|
||||
# Create drops
|
||||
new_state =
|
||||
if not Monster.drops_disabled?(updated_monster) do
|
||||
create_monster_drops(updated_monster, character_id, state)
|
||||
else
|
||||
%{state | monsters: new_monsters, spawn_points: new_spawn_points}
|
||||
end
|
||||
|
||||
{:reply, {:ok, :killed}, new_state}
|
||||
|
||||
{:ok, updated_monster, actual_damage} ->
|
||||
# Monster still alive
|
||||
new_monsters = Map.put(state.monsters, oid, updated_monster)
|
||||
new_state = %{state | monsters: new_monsters}
|
||||
|
||||
# Broadcast damage packet
|
||||
damage_packet = ChannelPackets.damage_monster(oid, actual_damage)
|
||||
broadcast_to_players(state.players, damage_packet)
|
||||
|
||||
{:reply, {:ok, :damaged}, new_state}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_cast({:broadcast, packet}, state) do
|
||||
broadcast_to_players(state.players, packet)
|
||||
@@ -259,6 +476,254 @@ defmodule Odinsea.Game.Map do
|
||||
{:noreply, state}
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Reactor Callbacks
|
||||
# ============================================================================
|
||||
|
||||
@impl true
|
||||
def handle_call({:hit_reactor, oid, _character_id, stance}, _from, state) do
|
||||
case Map.get(state.reactors, oid) do
|
||||
nil ->
|
||||
{:reply, {:error, :reactor_not_found}, state}
|
||||
|
||||
reactor ->
|
||||
if not reactor.alive do
|
||||
{:reply, {:error, :reactor_not_alive}, state}
|
||||
else
|
||||
# Advance reactor state
|
||||
old_state = reactor.state
|
||||
new_reactor = Reactor.advance_state(reactor)
|
||||
|
||||
# Check if reactor should be destroyed
|
||||
if Reactor.should_destroy?(new_reactor) do
|
||||
# Destroy reactor
|
||||
destroy_packet = ChannelPackets.destroy_reactor(new_reactor)
|
||||
broadcast_to_players(state.players, destroy_packet)
|
||||
|
||||
new_reactor = Reactor.set_alive(new_reactor, false)
|
||||
new_reactors = Map.put(state.reactors, oid, new_reactor)
|
||||
|
||||
# Schedule respawn if delay is set
|
||||
if new_reactor.delay > 0 do
|
||||
schedule_reactor_respawn(oid, new_reactor.delay)
|
||||
end
|
||||
|
||||
{:reply, {:ok, :destroyed}, %{state | reactors: new_reactors}}
|
||||
else
|
||||
# Trigger state change
|
||||
trigger_packet = ChannelPackets.trigger_reactor(new_reactor, stance)
|
||||
broadcast_to_players(state.players, trigger_packet)
|
||||
|
||||
# Check for timeout and schedule if needed
|
||||
timeout = Reactor.get_timeout(new_reactor)
|
||||
new_reactor =
|
||||
if timeout > 0 do
|
||||
Reactor.set_timer_active(new_reactor, true)
|
||||
else
|
||||
new_reactor
|
||||
end
|
||||
|
||||
new_reactors = Map.put(state.reactors, oid, new_reactor)
|
||||
|
||||
# If state changed, this might trigger a script
|
||||
script_trigger = old_state != new_reactor.state
|
||||
|
||||
{:reply, {:ok, %{state_changed: true, script_trigger: script_trigger}}, %{state | reactors: new_reactors}}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:destroy_reactor, oid}, _from, state) do
|
||||
case Map.get(state.reactors, oid) do
|
||||
nil ->
|
||||
{:reply, {:error, :reactor_not_found}, state}
|
||||
|
||||
reactor ->
|
||||
# Broadcast destroy
|
||||
destroy_packet = ChannelPackets.destroy_reactor(reactor)
|
||||
broadcast_to_players(state.players, destroy_packet)
|
||||
|
||||
new_reactor =
|
||||
reactor
|
||||
|> Reactor.set_alive(false)
|
||||
|> Reactor.set_timer_active(false)
|
||||
|
||||
new_reactors = Map.put(state.reactors, oid, new_reactor)
|
||||
|
||||
# Schedule respawn if delay is set
|
||||
if reactor.delay > 0 do
|
||||
schedule_reactor_respawn(oid, reactor.delay)
|
||||
end
|
||||
|
||||
{:reply, :ok, %{state | reactors: new_reactors}}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:get_reactor, oid}, _from, state) do
|
||||
{:reply, Map.get(state.reactors, oid), state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call(:get_reactors, _from, state) do
|
||||
{:reply, state.reactors, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_cast({:respawn_reactor, original_reactor}, state) do
|
||||
# Create a fresh copy of the reactor
|
||||
respawned =
|
||||
original_reactor
|
||||
|> Reactor.copy()
|
||||
|> Reactor.set_oid(state.next_oid)
|
||||
|
||||
new_reactors = Map.put(state.reactors, state.next_oid, respawned)
|
||||
|
||||
# Broadcast spawn
|
||||
spawn_packet = ChannelPackets.spawn_reactor(respawned)
|
||||
broadcast_to_players(state.players, spawn_packet)
|
||||
|
||||
Logger.debug("Reactor #{respawned.reactor_id} respawned on map #{state.map_id} with OID #{state.next_oid}")
|
||||
|
||||
{:noreply, %{state | reactors: new_reactors, next_oid: state.next_oid + 1}}
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Monster Callbacks
|
||||
# ============================================================================
|
||||
|
||||
@impl true
|
||||
def handle_cast({:spawn_monster, spawn_id}, state) do
|
||||
case Map.get(state.spawn_points, spawn_id) do
|
||||
nil ->
|
||||
Logger.warn("Spawn point #{spawn_id} not found on map #{state.map_id}")
|
||||
{:noreply, state}
|
||||
|
||||
spawn_point ->
|
||||
if spawn_point.spawned_oid do
|
||||
# Already spawned
|
||||
{:noreply, state}
|
||||
else
|
||||
# Spawn new monster
|
||||
{new_state, _oid} = do_spawn_monster(state, spawn_point, spawn_id)
|
||||
{:noreply, new_state}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_cast({:monster_killed, oid, killer_id}, state) do
|
||||
# Handle monster death (called externally)
|
||||
case Map.get(state.monsters, oid) do
|
||||
nil ->
|
||||
{:noreply, state}
|
||||
|
||||
monster ->
|
||||
# Remove monster
|
||||
new_monsters = Map.delete(state.monsters, oid)
|
||||
|
||||
# Find and update spawn point
|
||||
spawn_point_id = find_spawn_point_by_oid(state.spawn_points, oid)
|
||||
|
||||
new_spawn_points =
|
||||
if spawn_point_id do
|
||||
update_spawn_point(state.spawn_points, spawn_point_id, fn sp ->
|
||||
%{sp | spawned_oid: nil, last_spawn_time: DateTime.utc_now()}
|
||||
end)
|
||||
else
|
||||
state.spawn_points
|
||||
end
|
||||
|
||||
# Schedule respawn
|
||||
if spawn_point_id do
|
||||
spawn_point = Map.get(new_spawn_points, spawn_point_id)
|
||||
schedule_respawn(spawn_point_id, spawn_point.mob_time)
|
||||
end
|
||||
|
||||
# Create drops if killer_id is provided
|
||||
new_state =
|
||||
if killer_id && not Monster.drops_disabled?(monster) do
|
||||
monster_with_stats = %{monster | attackers: %{}} # Reset attackers since this is external
|
||||
create_monster_drops(monster, killer_id, %{state |
|
||||
monsters: new_monsters,
|
||||
spawn_points: new_spawn_points
|
||||
})
|
||||
else
|
||||
%{state | monsters: new_monsters, spawn_points: new_spawn_points}
|
||||
end
|
||||
|
||||
{:noreply, new_state}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info(:spawn_initial_monsters, state) do
|
||||
Logger.debug("Spawning initial monsters on map #{state.map_id}")
|
||||
|
||||
# Spawn all monsters at their spawn points
|
||||
new_state =
|
||||
Enum.reduce(state.spawn_points, state, fn {spawn_id, spawn_point}, acc_state ->
|
||||
{updated_state, _oid} = do_spawn_monster(acc_state, spawn_point, spawn_id)
|
||||
updated_state
|
||||
end)
|
||||
|
||||
{:noreply, new_state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:respawn_monster, spawn_id}, state) do
|
||||
# Respawn monster at spawn point
|
||||
case Map.get(state.spawn_points, spawn_id) do
|
||||
nil ->
|
||||
{:noreply, state}
|
||||
|
||||
spawn_point ->
|
||||
if spawn_point.spawned_oid do
|
||||
# Already spawned (shouldn't happen)
|
||||
{:noreply, state}
|
||||
else
|
||||
{new_state, _oid} = do_spawn_monster(state, spawn_point, spawn_id)
|
||||
{:noreply, new_state}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:respawn_reactor, oid}, state) do
|
||||
# Respawn a destroyed reactor
|
||||
case Map.get(state.reactors, oid) do
|
||||
nil ->
|
||||
{:noreply, state}
|
||||
|
||||
original_reactor ->
|
||||
if original_reactor.alive do
|
||||
# Already alive (shouldn't happen)
|
||||
{:noreply, state}
|
||||
else
|
||||
# Create a fresh copy
|
||||
respawned =
|
||||
original_reactor
|
||||
|> Reactor.copy()
|
||||
|> Reactor.set_oid(state.next_oid)
|
||||
|
||||
new_reactors =
|
||||
state.reactors
|
||||
|> Map.delete(oid) # Remove old destroyed reactor
|
||||
|> Map.put(state.next_oid, respawned)
|
||||
|
||||
# Broadcast spawn to players
|
||||
spawn_packet = ChannelPackets.spawn_reactor(respawned)
|
||||
broadcast_to_players(state.players, spawn_packet)
|
||||
|
||||
Logger.debug("Reactor #{respawned.reactor_id} respawned on map #{state.map_id} with OID #{state.next_oid}")
|
||||
|
||||
{:noreply, %{state | reactors: new_reactors, next_oid: state.next_oid + 1}}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Helper Functions
|
||||
# ============================================================================
|
||||
@@ -294,7 +759,390 @@ defmodule Odinsea.Game.Map do
|
||||
end)
|
||||
end
|
||||
|
||||
defp send_existing_monsters(client_pid, monsters) do
|
||||
Enum.each(monsters, fn {_oid, monster} ->
|
||||
spawn_packet = ChannelPackets.spawn_monster(monster, -1, 0)
|
||||
send_packet(client_pid, spawn_packet)
|
||||
end)
|
||||
end
|
||||
|
||||
defp send_existing_reactors(client_pid, reactors) do
|
||||
Enum.each(reactors, fn {_oid, reactor} ->
|
||||
spawn_packet = ChannelPackets.spawn_reactor(reactor)
|
||||
send_packet(client_pid, spawn_packet)
|
||||
end)
|
||||
end
|
||||
|
||||
defp send_packet(client_pid, packet) do
|
||||
send(client_pid, {:send_packet, packet})
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Monster Spawning Helpers
|
||||
# ============================================================================
|
||||
|
||||
defp load_spawn_points(template) do
|
||||
# Load spawn points from template
|
||||
template.spawn_points
|
||||
|> Enum.with_index()
|
||||
|> Enum.map(fn {sp, idx} ->
|
||||
spawn_point = %SpawnPoint{
|
||||
id: idx,
|
||||
mob_id: sp.mob_id,
|
||||
x: sp.x,
|
||||
y: sp.y,
|
||||
fh: sp.fh,
|
||||
cy: sp.cy,
|
||||
f: sp.f || 0,
|
||||
mob_time: sp.mob_time || 10_000,
|
||||
# Default 10 seconds
|
||||
spawned_oid: nil,
|
||||
last_spawn_time: nil,
|
||||
respawn_timer_ref: nil
|
||||
}
|
||||
|
||||
{idx, spawn_point}
|
||||
end)
|
||||
|> Map.new()
|
||||
rescue
|
||||
_e ->
|
||||
Logger.warn("Failed to load spawn points for map, using empty spawn list")
|
||||
%{}
|
||||
end
|
||||
|
||||
defp load_reactors(template, starting_oid) do
|
||||
# Load reactors from template reactor spawns
|
||||
{reactor_map, next_oid} =
|
||||
template.reactor_spawns
|
||||
|> Enum.with_index(starting_oid)
|
||||
|> Enum.reduce({%{}, starting_oid}, fn {rs, oid}, {acc_map, _acc_oid} ->
|
||||
case ReactorFactory.create_reactor(
|
||||
rs.reactor_id,
|
||||
rs.x,
|
||||
rs.y,
|
||||
rs.facing_direction,
|
||||
rs.name,
|
||||
rs.delay
|
||||
) do
|
||||
nil ->
|
||||
# Reactor stats not found, skip
|
||||
{acc_map, oid}
|
||||
|
||||
reactor ->
|
||||
# Assign OID to reactor
|
||||
reactor = Reactor.set_oid(reactor, oid)
|
||||
{Map.put(acc_map, oid, reactor), oid + 1}
|
||||
end
|
||||
end)
|
||||
|
||||
count = map_size(reactor_map)
|
||||
if count > 0 do
|
||||
Logger.debug("Loaded #{count} reactors on map #{template.map_id}")
|
||||
end
|
||||
|
||||
{reactor_map, next_oid}
|
||||
rescue
|
||||
_e ->
|
||||
Logger.warn("Failed to load reactors for map, using empty reactor list")
|
||||
{%{}, starting_oid}
|
||||
end
|
||||
|
||||
defp do_spawn_monster(state, spawn_point, spawn_id) do
|
||||
# Get monster stats from LifeFactory
|
||||
case LifeFactory.get_monster_stats(spawn_point.mob_id) do
|
||||
nil ->
|
||||
Logger.warn("Monster stats not found for mob_id #{spawn_point.mob_id}")
|
||||
{state, nil}
|
||||
|
||||
stats ->
|
||||
# Allocate OID
|
||||
oid = state.next_oid
|
||||
|
||||
# Create monster instance
|
||||
position = %{x: spawn_point.x, y: spawn_point.y, fh: spawn_point.fh}
|
||||
|
||||
monster = %Monster{
|
||||
oid: oid,
|
||||
mob_id: spawn_point.mob_id,
|
||||
stats: stats,
|
||||
hp: stats.hp,
|
||||
mp: stats.mp,
|
||||
max_hp: stats.hp,
|
||||
max_mp: stats.mp,
|
||||
position: position,
|
||||
stance: 5,
|
||||
# Default stance
|
||||
controller_id: nil,
|
||||
controller_has_aggro: false,
|
||||
spawn_effect: 0,
|
||||
team: -1,
|
||||
fake: false,
|
||||
link_oid: 0,
|
||||
status_effects: %{},
|
||||
poisons: [],
|
||||
attackers: %{},
|
||||
last_attack: System.system_time(:millisecond),
|
||||
last_move: System.system_time(:millisecond),
|
||||
last_skill_use: 0,
|
||||
killed: false,
|
||||
drops_disabled: false,
|
||||
create_time: System.system_time(:millisecond)
|
||||
}
|
||||
|
||||
# Add to monsters map
|
||||
new_monsters = Map.put(state.monsters, oid, monster)
|
||||
|
||||
# Update spawn point
|
||||
new_spawn_points =
|
||||
update_spawn_point(state.spawn_points, spawn_id, fn sp ->
|
||||
%{sp | spawned_oid: oid, last_spawn_time: DateTime.utc_now()}
|
||||
end)
|
||||
|
||||
# Broadcast monster spawn packet to all players
|
||||
spawn_packet = ChannelPackets.spawn_monster(monster, -1, 0)
|
||||
broadcast_to_players(state.players, spawn_packet)
|
||||
|
||||
Logger.debug("Spawned monster #{monster.mob_id} (OID: #{oid}) on map #{state.map_id}")
|
||||
|
||||
new_state = %{
|
||||
state
|
||||
| monsters: new_monsters,
|
||||
spawn_points: new_spawn_points,
|
||||
next_oid: oid + 1
|
||||
}
|
||||
|
||||
Logger.debug("Spawned monster #{spawn_point.mob_id} (OID: #{oid}) on map #{state.map_id}")
|
||||
|
||||
{new_state, oid}
|
||||
end
|
||||
end
|
||||
|
||||
defp find_spawn_point_by_oid(spawn_points, oid) do
|
||||
Enum.find_value(spawn_points, fn {spawn_id, sp} ->
|
||||
if sp.spawned_oid == oid, do: spawn_id, else: nil
|
||||
end)
|
||||
end
|
||||
|
||||
defp update_spawn_point(spawn_points, spawn_id, update_fn) do
|
||||
case Map.get(spawn_points, spawn_id) do
|
||||
nil -> spawn_points
|
||||
sp -> Map.put(spawn_points, spawn_id, update_fn.(sp))
|
||||
end
|
||||
end
|
||||
|
||||
defp schedule_respawn(spawn_id, mob_time) do
|
||||
# Schedule respawn message
|
||||
Process.send_after(self(), {:respawn_monster, spawn_id}, mob_time)
|
||||
end
|
||||
|
||||
defp schedule_reactor_respawn(oid, delay) do
|
||||
# Schedule reactor respawn message
|
||||
Process.send_after(self(), {:respawn_reactor, oid}, delay)
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# EXP Distribution
|
||||
# ============================================================================
|
||||
|
||||
defp distribute_exp(monster, players, _killer_id) do
|
||||
# Calculate base EXP from monster
|
||||
base_exp = calculate_monster_exp(monster)
|
||||
|
||||
if base_exp > 0 do
|
||||
# Find highest damage dealer
|
||||
{highest_attacker_id, _highest_damage} =
|
||||
Enum.max_by(
|
||||
monster.attackers,
|
||||
fn {_id, entry} -> entry.damage end,
|
||||
fn -> {nil, 0} end
|
||||
)
|
||||
|
||||
# Distribute EXP to all attackers
|
||||
Enum.each(monster.attackers, fn {attacker_id, attacker_data} ->
|
||||
# Calculate EXP share based on damage dealt
|
||||
damage_ratio = attacker_data.damage / max(1, monster.max_hp)
|
||||
attacker_exp = trunc(base_exp * min(1.0, damage_ratio))
|
||||
|
||||
is_highest = attacker_id == highest_attacker_id
|
||||
|
||||
# Find character and give EXP
|
||||
case find_character_pid(attacker_id) do
|
||||
{:ok, character_pid} ->
|
||||
give_exp_to_character(character_pid, attacker_exp, is_highest, monster)
|
||||
|
||||
{:error, _} ->
|
||||
Logger.debug("Character #{attacker_id} not found for EXP distribution")
|
||||
end
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
defp calculate_monster_exp(monster) do
|
||||
# Base EXP from monster stats
|
||||
base = monster.stats.exp
|
||||
|
||||
# Apply any multipliers
|
||||
# TODO: Add event multipliers, premium account bonuses, etc.
|
||||
base
|
||||
end
|
||||
|
||||
defp give_exp_to_character(character_pid, exp_amount, is_highest, monster) do
|
||||
# TODO: Apply EXP buffs (Holy Symbol, exp cards, etc.)
|
||||
# TODO: Apply level difference penalties
|
||||
# TODO: Apply server rates
|
||||
|
||||
final_exp = exp_amount
|
||||
|
||||
# Give EXP to character
|
||||
case Character.gain_exp(character_pid, final_exp, is_highest) do
|
||||
:ok ->
|
||||
Logger.debug("Gave #{final_exp} EXP to character (highest: #{is_highest})")
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warning("Failed to give EXP to character: #{inspect(reason)}")
|
||||
end
|
||||
end
|
||||
|
||||
defp find_character_pid(character_id) do
|
||||
case Registry.lookup(Odinsea.CharacterRegistry, character_id) do
|
||||
[{pid, _}] -> {:ok, pid}
|
||||
[] -> {:error, :not_found}
|
||||
end
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Drop System
|
||||
# ============================================================================
|
||||
|
||||
defp create_monster_drops(monster, killer_id, state) do
|
||||
# Get monster position
|
||||
position = monster.position
|
||||
|
||||
# Calculate drop rate multiplier (from map/server rates)
|
||||
drop_rate_multiplier = state.drop_rate
|
||||
|
||||
# Create drops
|
||||
drops = DropSystem.create_monster_drops(
|
||||
monster.mob_id,
|
||||
killer_id,
|
||||
position,
|
||||
state.next_oid,
|
||||
drop_rate_multiplier
|
||||
)
|
||||
|
||||
# Also create global drops
|
||||
global_drops = DropSystem.create_global_drops(
|
||||
killer_id,
|
||||
position,
|
||||
state.next_oid + length(drops),
|
||||
drop_rate_multiplier
|
||||
)
|
||||
|
||||
all_drops = drops ++ global_drops
|
||||
|
||||
if length(all_drops) > 0 do
|
||||
# Add drops to map state
|
||||
new_items =
|
||||
Enum.reduce(all_drops, state.items, fn drop, items ->
|
||||
Map.put(items, drop.oid, drop)
|
||||
end)
|
||||
|
||||
# Broadcast drop spawn packets
|
||||
Enum.each(all_drops, fn drop ->
|
||||
spawn_packet = ChannelPackets.spawn_drop(drop, position, 1)
|
||||
broadcast_to_players(state.players, spawn_packet)
|
||||
end)
|
||||
|
||||
# Update next OID
|
||||
next_oid = state.next_oid + length(all_drops)
|
||||
|
||||
Logger.debug("Created #{length(all_drops)} drops on map #{state.map_id}")
|
||||
|
||||
%{state | items: new_items, next_oid: next_oid}
|
||||
else
|
||||
state
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets all drops on the map.
|
||||
"""
|
||||
def get_drops(map_id, channel_id) do
|
||||
GenServer.call(via_tuple(map_id, channel_id), :get_drops)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Attempts to pick up a drop.
|
||||
"""
|
||||
def pickup_drop(map_id, channel_id, drop_oid, character_id) do
|
||||
GenServer.call(via_tuple(map_id, channel_id), {:pickup_drop, drop_oid, character_id})
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call(:get_drops, _from, state) do
|
||||
{:reply, state.items, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:pickup_drop, drop_oid, character_id}, _from, state) do
|
||||
case Map.get(state.items, drop_oid) do
|
||||
nil ->
|
||||
{:reply, {:error, :drop_not_found}, state}
|
||||
|
||||
drop ->
|
||||
now = System.system_time(:millisecond)
|
||||
|
||||
case DropSystem.pickup_drop(drop, character_id, now) do
|
||||
{:ok, updated_drop} ->
|
||||
# Broadcast pickup animation
|
||||
remove_packet = ChannelPackets.remove_drop(drop_oid, 2, character_id)
|
||||
broadcast_to_players(state.players, remove_packet)
|
||||
|
||||
# Remove from map
|
||||
new_items = Map.delete(state.items, drop_oid)
|
||||
|
||||
# Return drop info for inventory addition
|
||||
{:reply, {:ok, updated_drop}, %{state | items: new_items}}
|
||||
|
||||
{:error, reason} ->
|
||||
{:reply, {:error, reason}, state}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info(:check_drop_expiration, state) do
|
||||
now = System.system_time(:millisecond)
|
||||
|
||||
# Check for expired drops
|
||||
{expired_drops, valid_drops} =
|
||||
Enum.split_with(state.items, fn {_oid, drop} ->
|
||||
Drop.should_expire?(drop, now)
|
||||
end)
|
||||
|
||||
# Broadcast expiration for expired drops
|
||||
Enum.each(expired_drops, fn {oid, _drop} ->
|
||||
expire_packet = ChannelPackets.remove_drop(oid, 0, 0)
|
||||
broadcast_to_players(state.players, expire_packet)
|
||||
end)
|
||||
|
||||
# Convert valid drops back to map
|
||||
new_items = Map.new(valid_drops)
|
||||
|
||||
# Schedule next check if there are drops remaining
|
||||
if map_size(new_items) > 0 do
|
||||
Process.send_after(self(), :check_drop_expiration, 10_000)
|
||||
end
|
||||
|
||||
{:noreply, %{state | items: new_items}}
|
||||
end
|
||||
|
||||
defp send_existing_items(client_pid, items) do
|
||||
Enum.each(items, fn {_oid, drop} ->
|
||||
if not drop.picked_up do
|
||||
packet = ChannelPackets.spawn_drop(drop, nil, 2)
|
||||
send_packet(client_pid, packet)
|
||||
end
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -127,6 +127,52 @@ defmodule Odinsea.Game.MapFactory do
|
||||
]
|
||||
end
|
||||
|
||||
defmodule SpawnPoint do
|
||||
@moduledoc "Represents a monster spawn point on a map"
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
mob_id: integer(),
|
||||
x: integer(),
|
||||
y: integer(),
|
||||
fh: integer(),
|
||||
cy: integer(),
|
||||
f: integer(),
|
||||
mob_time: integer()
|
||||
}
|
||||
|
||||
defstruct [
|
||||
:mob_id,
|
||||
:x,
|
||||
:y,
|
||||
:fh,
|
||||
:cy,
|
||||
:f,
|
||||
:mob_time
|
||||
]
|
||||
end
|
||||
|
||||
defmodule ReactorSpawn do
|
||||
@moduledoc "Represents a reactor spawn point on a map"
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
reactor_id: integer(),
|
||||
x: integer(),
|
||||
y: integer(),
|
||||
facing_direction: integer(),
|
||||
name: String.t(),
|
||||
delay: integer()
|
||||
}
|
||||
|
||||
defstruct [
|
||||
:reactor_id,
|
||||
:x,
|
||||
:y,
|
||||
facing_direction: 0,
|
||||
name: "",
|
||||
delay: 0
|
||||
]
|
||||
end
|
||||
|
||||
defmodule FieldTemplate do
|
||||
@moduledoc "Map field template containing all map data"
|
||||
|
||||
@@ -143,7 +189,8 @@ defmodule Odinsea.Game.MapFactory do
|
||||
dec_hp_interval: integer(),
|
||||
portal_map: %{String.t() => Portal.t()},
|
||||
portals: [Portal.t()],
|
||||
spawn_points: [Portal.t()],
|
||||
spawn_points: [SpawnPoint.t()],
|
||||
reactor_spawns: [ReactorSpawn.t()],
|
||||
footholds: [Foothold.t()],
|
||||
top: integer(),
|
||||
bottom: integer(),
|
||||
@@ -175,6 +222,7 @@ defmodule Odinsea.Game.MapFactory do
|
||||
:portal_map,
|
||||
:portals,
|
||||
:spawn_points,
|
||||
:reactor_spawns,
|
||||
:footholds,
|
||||
:top,
|
||||
:bottom,
|
||||
@@ -341,10 +389,15 @@ defmodule Odinsea.Game.MapFactory do
|
||||
|> Enum.map(fn portal -> {portal.name, portal} end)
|
||||
|> Enum.into(%{})
|
||||
|
||||
# Parse spawn points
|
||||
spawn_points =
|
||||
Enum.filter(portals, fn portal ->
|
||||
portal.type == :spawn || portal.name == "sp"
|
||||
end)
|
||||
(map_data[:spawns] || [])
|
||||
|> Enum.map(&build_spawn_point/1)
|
||||
|
||||
# Parse reactor spawns
|
||||
reactor_spawns =
|
||||
(map_data[:reactors] || [])
|
||||
|> Enum.map(&build_reactor_spawn/1)
|
||||
|
||||
# Parse footholds
|
||||
footholds =
|
||||
@@ -365,6 +418,7 @@ defmodule Odinsea.Game.MapFactory do
|
||||
portal_map: portal_map,
|
||||
portals: portals,
|
||||
spawn_points: spawn_points,
|
||||
reactor_spawns: reactor_spawns,
|
||||
footholds: footholds,
|
||||
top: map_data[:top] || 0,
|
||||
bottom: map_data[:bottom] || 0,
|
||||
@@ -415,6 +469,29 @@ defmodule Odinsea.Game.MapFactory do
|
||||
}
|
||||
end
|
||||
|
||||
defp build_spawn_point(spawn_data) do
|
||||
%SpawnPoint{
|
||||
mob_id: spawn_data[:mob_id] || 0,
|
||||
x: spawn_data[:x] || 0,
|
||||
y: spawn_data[:y] || 0,
|
||||
fh: spawn_data[:fh] || 0,
|
||||
cy: spawn_data[:cy] || 0,
|
||||
f: spawn_data[:f] || 0,
|
||||
mob_time: spawn_data[:mob_time] || 10_000
|
||||
}
|
||||
end
|
||||
|
||||
defp build_reactor_spawn(reactor_data) do
|
||||
%ReactorSpawn{
|
||||
reactor_id: reactor_data[:reactor_id] || reactor_data[:id] || 0,
|
||||
x: reactor_data[:x] || 0,
|
||||
y: reactor_data[:y] || 0,
|
||||
facing_direction: reactor_data[:f] || reactor_data[:facing_direction] || 0,
|
||||
name: reactor_data[:name] || "",
|
||||
delay: reactor_data[:reactor_time] || reactor_data[:delay] || 0
|
||||
}
|
||||
end
|
||||
|
||||
# Fallback data for basic testing
|
||||
defp create_fallback_maps do
|
||||
# Common beginner maps
|
||||
@@ -441,7 +518,7 @@ defmodule Odinsea.Game.MapFactory do
|
||||
%{id: 0, name: "sp", type: "sp", x: -1283, y: 86, target_map: 100000000, target_portal: ""}
|
||||
]
|
||||
},
|
||||
# Henesys Hunting Ground I
|
||||
# Henesys Hunting Ground I - with monsters!
|
||||
%{
|
||||
map_id: 100010000,
|
||||
map_name: "Henesys Hunting Ground I",
|
||||
@@ -450,6 +527,15 @@ defmodule Odinsea.Game.MapFactory do
|
||||
forced_return: 100000000,
|
||||
portals: [
|
||||
%{id: 0, name: "sp", type: "sp", x: 0, y: 0, target_map: 100010000, target_portal: ""}
|
||||
],
|
||||
spawns: [
|
||||
# Blue Snails (mob_id: 100001)
|
||||
%{mob_id: 100001, x: -500, y: 100, fh: 0, cy: 0, f: 0, mob_time: 8000},
|
||||
%{mob_id: 100001, x: -200, y: 100, fh: 0, cy: 0, f: 1, mob_time: 8000},
|
||||
%{mob_id: 100001, x: 200, y: 100, fh: 0, cy: 0, f: 0, mob_time: 8000},
|
||||
# Orange Mushrooms (mob_id: 1210102)
|
||||
%{mob_id: 1210102, x: 500, y: 100, fh: 0, cy: 0, f: 1, mob_time: 10000},
|
||||
%{mob_id: 1210102, x: 800, y: 100, fh: 0, cy: 0, f: 0, mob_time: 10000}
|
||||
]
|
||||
},
|
||||
# Hidden Street - FM Entrance
|
||||
|
||||
645
lib/odinsea/game/mini_game.ex
Normal file
645
lib/odinsea/game/mini_game.ex
Normal file
@@ -0,0 +1,645 @@
|
||||
defmodule Odinsea.Game.MiniGame do
|
||||
@moduledoc """
|
||||
Mini Game system for Omok and Match Card games in player shops.
|
||||
Ported from src/server/shops/MapleMiniGame.java
|
||||
|
||||
Mini games allow players to:
|
||||
- Play Omok (5-in-a-row)
|
||||
- Play Match Card (memory game)
|
||||
- Track wins/losses/ties
|
||||
- Earn game points
|
||||
|
||||
Game Types:
|
||||
- 1 = Omok (5-in-a-row)
|
||||
- 2 = Match Card (memory matching)
|
||||
|
||||
Game lifecycle:
|
||||
1. Owner creates game with type and description
|
||||
2. Visitor joins and both mark ready
|
||||
3. Game starts and players take turns
|
||||
4. Game ends with win/loss/tie
|
||||
"""
|
||||
|
||||
use GenServer
|
||||
require Logger
|
||||
|
||||
# Game type constants
|
||||
@game_type_omok 1
|
||||
@game_type_match_card 2
|
||||
|
||||
# Shop type constants (from IMaplePlayerShop)
|
||||
@shop_type_omok 3
|
||||
@shop_type_match_card 4
|
||||
|
||||
# Board size for Omok
|
||||
@omok_board_size 15
|
||||
|
||||
# Default slots for mini games
|
||||
@max_slots 2
|
||||
|
||||
# Struct for the mini game state
|
||||
defstruct [
|
||||
:id,
|
||||
:owner_id,
|
||||
:owner_name,
|
||||
:item_id,
|
||||
:description,
|
||||
:password,
|
||||
:game_type,
|
||||
:piece_type,
|
||||
:map_id,
|
||||
:channel,
|
||||
:visitors,
|
||||
:ready,
|
||||
:points,
|
||||
:exit_after,
|
||||
:open,
|
||||
:available,
|
||||
# Omok specific
|
||||
:board,
|
||||
:loser,
|
||||
:turn,
|
||||
# Match card specific
|
||||
:match_cards,
|
||||
:first_slot,
|
||||
:tie_requested
|
||||
]
|
||||
|
||||
@doc """
|
||||
Starts a new mini game GenServer.
|
||||
"""
|
||||
def start_link(opts) do
|
||||
game_id = Keyword.fetch!(opts, :id)
|
||||
GenServer.start_link(__MODULE__, opts, name: via_tuple(game_id))
|
||||
end
|
||||
|
||||
@doc """
|
||||
Creates a new mini game.
|
||||
"""
|
||||
def create(opts) do
|
||||
game_type = opts[:game_type] || @game_type_omok
|
||||
|
||||
%__MODULE__{
|
||||
id: opts[:id] || generate_id(),
|
||||
owner_id: opts[:owner_id],
|
||||
owner_name: opts[:owner_name],
|
||||
item_id: opts[:item_id],
|
||||
description: opts[:description] || "",
|
||||
password: opts[:password] || "",
|
||||
game_type: game_type,
|
||||
piece_type: opts[:piece_type] || 0,
|
||||
map_id: opts[:map_id],
|
||||
channel: opts[:channel],
|
||||
visitors: %{},
|
||||
ready: {false, false},
|
||||
points: {0, 0},
|
||||
exit_after: {false, false},
|
||||
open: true,
|
||||
available: true,
|
||||
# Omok board (15x15 grid)
|
||||
board: create_empty_board(),
|
||||
loser: 0,
|
||||
turn: 1,
|
||||
# Match card
|
||||
match_cards: [],
|
||||
first_slot: 0,
|
||||
tie_requested: -1
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the shop type for this game.
|
||||
"""
|
||||
def shop_type(%__MODULE__{game_type: type}) do
|
||||
case type do
|
||||
@game_type_omok -> @shop_type_omok
|
||||
@game_type_match_card -> @shop_type_match_card
|
||||
_ -> @shop_type_omok
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the game type constant.
|
||||
"""
|
||||
def game_type_omok, do: @game_type_omok
|
||||
def game_type_match_card, do: @game_type_match_card
|
||||
|
||||
@doc """
|
||||
Gets the current game state.
|
||||
"""
|
||||
def get_state(game_pid) when is_pid(game_pid) do
|
||||
GenServer.call(game_pid, :get_state)
|
||||
end
|
||||
|
||||
def get_state(game_id) do
|
||||
case lookup(game_id) do
|
||||
{:ok, pid} -> get_state(pid)
|
||||
error -> error
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Looks up a game by ID.
|
||||
"""
|
||||
def lookup(game_id) do
|
||||
case Registry.lookup(Odinsea.MiniGameRegistry, game_id) do
|
||||
[{pid, _}] -> {:ok, pid}
|
||||
[] -> {:error, :not_found}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Adds a visitor to the game.
|
||||
Returns the visitor slot or {:error, reason}.
|
||||
"""
|
||||
def add_visitor(game_id, character_id, character_pid) do
|
||||
with {:ok, pid} <- lookup(game_id) do
|
||||
GenServer.call(pid, {:add_visitor, character_id, character_pid})
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Removes a visitor from the game.
|
||||
"""
|
||||
def remove_visitor(game_id, character_id) do
|
||||
with {:ok, pid} <- lookup(game_id) do
|
||||
GenServer.call(pid, {:remove_visitor, character_id})
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Sets a player as ready/not ready.
|
||||
"""
|
||||
def set_ready(game_id, character_id) do
|
||||
with {:ok, pid} <- lookup(game_id) do
|
||||
GenServer.call(pid, {:set_ready, character_id})
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if a player is ready.
|
||||
"""
|
||||
def is_ready?(game_id, character_id) do
|
||||
with {:ok, pid} <- lookup(game_id) do
|
||||
GenServer.call(pid, {:is_ready, character_id})
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Starts the game (if all players ready).
|
||||
"""
|
||||
def start_game(game_id) do
|
||||
with {:ok, pid} <- lookup(game_id) do
|
||||
GenServer.call(pid, :start_game)
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Makes an Omok move.
|
||||
"""
|
||||
def make_omok_move(game_id, character_id, x, y, piece_type) do
|
||||
with {:ok, pid} <- lookup(game_id) do
|
||||
GenServer.call(pid, {:omok_move, character_id, x, y, piece_type})
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Selects a card in Match Card game.
|
||||
"""
|
||||
def select_card(game_id, character_id, slot) do
|
||||
with {:ok, pid} <- lookup(game_id) do
|
||||
GenServer.call(pid, {:select_card, character_id, slot})
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Requests a tie.
|
||||
"""
|
||||
def request_tie(game_id, character_id) do
|
||||
with {:ok, pid} <- lookup(game_id) do
|
||||
GenServer.call(pid, {:request_tie, character_id})
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Answers a tie request.
|
||||
"""
|
||||
def answer_tie(game_id, character_id, accept) do
|
||||
with {:ok, pid} <- lookup(game_id) do
|
||||
GenServer.call(pid, {:answer_tie, character_id, accept})
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Skips turn (forfeits move).
|
||||
"""
|
||||
def skip_turn(game_id, character_id) do
|
||||
with {:ok, pid} <- lookup(game_id) do
|
||||
GenServer.call(pid, {:skip_turn, character_id})
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gives up (forfeits game).
|
||||
"""
|
||||
def give_up(game_id, character_id) do
|
||||
with {:ok, pid} <- lookup(game_id) do
|
||||
GenServer.call(pid, {:give_up, character_id})
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Sets exit after game flag.
|
||||
"""
|
||||
def set_exit_after(game_id, character_id) do
|
||||
with {:ok, pid} <- lookup(game_id) do
|
||||
GenServer.call(pid, {:set_exit_after, character_id})
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if player wants to exit after game.
|
||||
"""
|
||||
def is_exit_after?(game_id, character_id) do
|
||||
with {:ok, pid} <- lookup(game_id) do
|
||||
GenServer.call(pid, {:is_exit_after, character_id})
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the visitor slot for a character.
|
||||
"""
|
||||
def get_visitor_slot(game_id, character_id) do
|
||||
with {:ok, pid} <- lookup(game_id) do
|
||||
GenServer.call(pid, {:get_visitor_slot, character_id})
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if character is the owner.
|
||||
"""
|
||||
def is_owner?(game_id, character_id) do
|
||||
with {:ok, pid} <- lookup(game_id) do
|
||||
GenServer.call(pid, {:is_owner, character_id})
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Closes the game.
|
||||
"""
|
||||
def close_game(game_id) do
|
||||
with {:ok, pid} <- lookup(game_id) do
|
||||
GenServer.call(pid, :close_game)
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the number of matches needed to win.
|
||||
"""
|
||||
def get_matches_to_win(piece_type) do
|
||||
case piece_type do
|
||||
0 -> 6
|
||||
1 -> 10
|
||||
2 -> 15
|
||||
_ -> 6
|
||||
end
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# GenServer Callbacks
|
||||
# ============================================================================
|
||||
|
||||
@impl true
|
||||
def init(opts) do
|
||||
state = create(opts)
|
||||
{:ok, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call(:get_state, _from, state) do
|
||||
{:reply, state, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:add_visitor, character_id, character_pid}, _from, state) do
|
||||
visitor_count = map_size(state.visitors)
|
||||
|
||||
if visitor_count >= @max_slots - 1 do
|
||||
{:reply, {:error, :full}, state}
|
||||
else
|
||||
slot = visitor_count + 1
|
||||
new_visitors = Map.put(state.visitors, character_id, %{pid: character_pid, slot: slot})
|
||||
{:reply, {:ok, slot}, %{state | visitors: new_visitors}}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:remove_visitor, character_id}, _from, state) do
|
||||
new_visitors = Map.delete(state.visitors, character_id)
|
||||
{:reply, :ok, %{state | visitors: new_visitors}}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:set_ready, character_id}, _from, state) do
|
||||
slot = get_slot_for_character_internal(state, character_id)
|
||||
|
||||
if slot > 0 do
|
||||
{r0, r1} = state.ready
|
||||
new_ready = if slot == 1, do: {not r0, r1}, else: {r0, not r1}
|
||||
{:reply, :ok, %{state | ready: new_ready}}
|
||||
else
|
||||
{:reply, {:error, :not_visitor}, state}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:is_ready, character_id}, _from, state) do
|
||||
slot = get_slot_for_character_internal(state, character_id)
|
||||
{r0, r1} = state.ready
|
||||
ready = if slot == 1, do: r0, else: r1
|
||||
{:reply, ready, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call(:start_game, _from, state) do
|
||||
{r0, r1} = state.ready
|
||||
|
||||
if r0 and r1 do
|
||||
# Initialize game based on type
|
||||
new_state =
|
||||
case state.game_type do
|
||||
@game_type_omok ->
|
||||
%{state | board: create_empty_board(), open: false}
|
||||
|
||||
@game_type_match_card ->
|
||||
cards = generate_match_cards(state.piece_type)
|
||||
%{state | match_cards: cards, open: false}
|
||||
|
||||
_ ->
|
||||
%{state | open: false}
|
||||
end
|
||||
|
||||
{:reply, {:ok, new_state.loser}, new_state}
|
||||
else
|
||||
{:reply, {:error, :not_ready}, state}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:omok_move, character_id, x, y, piece_type}, _from, state) do
|
||||
# Check if it's this player's turn (loser goes first)
|
||||
slot = get_slot_for_character_internal(state, character_id)
|
||||
|
||||
if slot != state.loser + 1 do
|
||||
{:reply, {:error, :not_your_turn}, state}
|
||||
else
|
||||
# Check if position is valid and empty
|
||||
if x < 0 or x >= @omok_board_size or y < 0 or y >= @omok_board_size do
|
||||
{:reply, {:error, :invalid_position}, state}
|
||||
else
|
||||
current_piece = get_board_piece(state.board, x, y)
|
||||
|
||||
if current_piece != 0 do
|
||||
{:reply, {:error, :position_occupied}, state}
|
||||
else
|
||||
# Place piece
|
||||
new_board = set_board_piece(state.board, x, y, piece_type)
|
||||
|
||||
# Check for win
|
||||
won = check_omok_win(new_board, x, y, piece_type)
|
||||
|
||||
# Next turn
|
||||
next_loser = rem(state.loser + 1, @max_slots)
|
||||
|
||||
new_state = %{
|
||||
state
|
||||
| board: new_board,
|
||||
loser: next_loser
|
||||
}
|
||||
|
||||
if won do
|
||||
# Award point
|
||||
{p0, p1} = state.points
|
||||
new_points = if slot == 1, do: {p0 + 1, p1}, else: {p0, p1 + 1}
|
||||
{:reply, {:win, slot}, %{new_state | points: new_points, open: true}}
|
||||
else
|
||||
{:reply, {:ok, won}, new_state}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:select_card, character_id, slot}, _from, state) do
|
||||
# Match card logic
|
||||
slot = get_slot_for_character_internal(state, character_id)
|
||||
|
||||
if slot != state.loser + 1 do
|
||||
{:reply, {:error, :not_your_turn}, state}
|
||||
else
|
||||
# Simplified match card logic
|
||||
# In full implementation, track first/second card selection and matching
|
||||
|
||||
turn = state.turn
|
||||
|
||||
if turn == 1 do
|
||||
# First card
|
||||
{:reply, {:first_card, slot}, %{state | first_slot: slot, turn: 0}}
|
||||
else
|
||||
# Second card - check match
|
||||
first_card = Enum.at(state.match_cards, state.first_slot - 1)
|
||||
second_card = Enum.at(state.match_cards, slot - 1)
|
||||
|
||||
if first_card == second_card do
|
||||
# Match! Award point
|
||||
{p0, p1} = state.points
|
||||
new_points = if slot == 1, do: {p0 + 1, p1}, else: {p0, p1 + 1}
|
||||
|
||||
# Check for game win
|
||||
{p0_new, p1_new} = new_points
|
||||
matches_needed = get_matches_to_win(state.piece_type)
|
||||
|
||||
if p0_new >= matches_needed or p1_new >= matches_needed do
|
||||
{:reply, {:game_win, slot}, %{state | points: new_points, turn: 1, open: true}}
|
||||
else
|
||||
{:reply, {:match, slot}, %{state | points: new_points, turn: 1}}
|
||||
end
|
||||
else
|
||||
# No match, switch turns
|
||||
next_loser = rem(state.loser + 1, @max_slots)
|
||||
{:reply, {:no_match, slot}, %{state | turn: 1, loser: next_loser}}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:request_tie, character_id}, _from, state) do
|
||||
slot = get_slot_for_character_internal(state, character_id)
|
||||
|
||||
if state.tie_requested == -1 do
|
||||
{:reply, :ok, %{state | tie_requested: slot}}
|
||||
else
|
||||
{:reply, {:error, :already_requested}, state}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:answer_tie, character_id, accept}, _from, state) do
|
||||
slot = get_slot_for_character_internal(state, character_id)
|
||||
|
||||
if state.tie_requested != -1 and state.tie_requested != slot do
|
||||
if accept do
|
||||
# Tie accepted
|
||||
{p0, p1} = state.points
|
||||
{:reply, {:tie, slot}, %{state | tie_requested: -1, points: {p0, p1}, open: true}}
|
||||
else
|
||||
{:reply, {:deny, slot}, %{state | tie_requested: -1}}
|
||||
end
|
||||
else
|
||||
{:reply, {:error, :invalid_request}, state}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:skip_turn, character_id}, _from, state) do
|
||||
slot = get_slot_for_character_internal(state, character_id)
|
||||
|
||||
if slot == state.loser + 1 do
|
||||
next_loser = rem(state.loser + 1, @max_slots)
|
||||
{:reply, :ok, %{state | loser: next_loser}}
|
||||
else
|
||||
{:reply, {:error, :not_your_turn}, state}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:give_up, character_id}, _from, state) do
|
||||
slot = get_slot_for_character_internal(state, character_id)
|
||||
# Other player wins
|
||||
winner = if slot == 1, do: 2, else: 1
|
||||
{:reply, {:give_up, winner}, %{state | open: true}}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:set_exit_after, character_id}, _from, state) do
|
||||
slot = get_slot_for_character_internal(state, character_id)
|
||||
|
||||
if slot > 0 do
|
||||
{e0, e1} = state.exit_after
|
||||
new_exit = if slot == 1, do: {not e0, e1}, else: {e0, not e1}
|
||||
{:reply, :ok, %{state | exit_after: new_exit}}
|
||||
else
|
||||
{:reply, {:error, :not_visitor}, state}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:is_exit_after, character_id}, _from, state) do
|
||||
slot = get_slot_for_character_internal(state, character_id)
|
||||
{e0, e1} = state.exit_after
|
||||
exit = if slot == 1, do: e0, else: e1
|
||||
{:reply, exit, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:get_visitor_slot, character_id}, _from, state) do
|
||||
slot = get_slot_for_character_internal(state, character_id)
|
||||
{:reply, slot, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:is_owner, character_id}, _from, state) do
|
||||
{:reply, character_id == state.owner_id, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call(:close_game, _from, state) do
|
||||
# Remove all visitors
|
||||
Enum.each(state.visitors, fn {_id, data} ->
|
||||
send(data.pid, {:game_closed, state.id})
|
||||
end)
|
||||
|
||||
{:stop, :normal, :ok, state}
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Private Helper Functions
|
||||
# ============================================================================
|
||||
|
||||
defp via_tuple(game_id) do
|
||||
{:via, Registry, {Odinsea.MiniGameRegistry, game_id}}
|
||||
end
|
||||
|
||||
defp generate_id do
|
||||
:erlang.unique_integer([:positive])
|
||||
end
|
||||
|
||||
defp get_slot_for_character_internal(state, character_id) do
|
||||
cond do
|
||||
character_id == state.owner_id -> 1
|
||||
true -> Map.get(state.visitors, character_id, %{}) |> Map.get(:slot, -1)
|
||||
end
|
||||
end
|
||||
|
||||
defp create_empty_board do
|
||||
for _ <- 1..@omok_board_size do
|
||||
for _ <- 1..@omok_board_size, do: 0
|
||||
end
|
||||
end
|
||||
|
||||
defp get_board_piece(board, x, y) do
|
||||
row = Enum.at(board, y)
|
||||
Enum.at(row, x)
|
||||
end
|
||||
|
||||
defp set_board_piece(board, x, y, piece) do
|
||||
row = Enum.at(board, y)
|
||||
new_row = List.replace_at(row, x, piece)
|
||||
List.replace_at(board, y, new_row)
|
||||
end
|
||||
|
||||
defp generate_match_cards(piece_type) do
|
||||
matches_needed = get_matches_to_win(piece_type)
|
||||
|
||||
cards =
|
||||
for i <- 0..(matches_needed - 1) do
|
||||
[i, i]
|
||||
end
|
||||
|> List.flatten()
|
||||
|
||||
# Shuffle cards
|
||||
Enum.shuffle(cards)
|
||||
end
|
||||
|
||||
# Omok win checking - check all directions from the last move
|
||||
defp check_omok_win(board, x, y, piece_type) do
|
||||
directions = [
|
||||
{1, 0}, # Horizontal
|
||||
{0, 1}, # Vertical
|
||||
{1, 1}, # Diagonal \
|
||||
{1, -1} # Diagonal /
|
||||
]
|
||||
|
||||
Enum.any?(directions, fn {dx, dy} ->
|
||||
count = count_in_direction(board, x, y, dx, dy, piece_type) +
|
||||
count_in_direction(board, x, y, -dx, -dy, piece_type) - 1
|
||||
count >= 5
|
||||
end)
|
||||
end
|
||||
|
||||
defp count_in_direction(board, x, y, dx, dy, piece_type, count \\ 0) do
|
||||
if x < 0 or x >= @omok_board_size or y < 0 or y >= @omok_board_size do
|
||||
count
|
||||
else
|
||||
piece = get_board_piece(board, x, y)
|
||||
|
||||
if piece == piece_type do
|
||||
count_in_direction(board, x + dx, y + dy, dx, dy, piece_type, count + 1)
|
||||
else
|
||||
count
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
309
lib/odinsea/game/monster_status.ex
Normal file
309
lib/odinsea/game/monster_status.ex
Normal file
@@ -0,0 +1,309 @@
|
||||
defmodule Odinsea.Game.MonsterStatus do
|
||||
@moduledoc """
|
||||
Monster status effects (buffs/debuffs) for MapleStory.
|
||||
|
||||
Ported from Java: client/status/MobStat.java
|
||||
|
||||
MonsterStatus represents status effects that can be applied to monsters:
|
||||
- PAD (Physical Attack Damage)
|
||||
- PDD (Physical Defense)
|
||||
- MAD (Magic Attack Damage)
|
||||
- MDD (Magic Defense)
|
||||
- ACC (Accuracy)
|
||||
- EVA (Evasion)
|
||||
- Speed
|
||||
- Stun
|
||||
- Freeze
|
||||
- Poison
|
||||
- Seal
|
||||
- And more...
|
||||
"""
|
||||
|
||||
import Bitwise
|
||||
|
||||
@type t ::
|
||||
:pad
|
||||
| :pdd
|
||||
| :mad
|
||||
| :mdd
|
||||
| :acc
|
||||
| :eva
|
||||
| :speed
|
||||
| :stun
|
||||
| :freeze
|
||||
| :poison
|
||||
| :seal
|
||||
| :darkness
|
||||
| :power_up
|
||||
| :magic_up
|
||||
| :p_guard_up
|
||||
| :m_guard_up
|
||||
| :doom
|
||||
| :web
|
||||
| :p_immune
|
||||
| :m_immune
|
||||
| :showdown
|
||||
| :hard_skin
|
||||
| :ambush
|
||||
| :damaged_elem_attr
|
||||
| :venom
|
||||
| :blind
|
||||
| :seal_skill
|
||||
| :burned
|
||||
| :dazzle
|
||||
| :p_counter
|
||||
| :m_counter
|
||||
| :disable
|
||||
| :rise_by_toss
|
||||
| :body_pressure
|
||||
| :weakness
|
||||
| :time_bomb
|
||||
| :magic_crash
|
||||
| :exchange_attack
|
||||
| :heal_by_damage
|
||||
| :invincible
|
||||
|
||||
@doc """
|
||||
All monster status effects with their bit values and positions.
|
||||
Format: {status_name, bit_value, position}
|
||||
Position 1 = first int, Position 2 = second int
|
||||
"""
|
||||
def all_statuses do
|
||||
[
|
||||
# Position 1 (first int)
|
||||
{:pad, 0x1, 1},
|
||||
{:pdd, 0x2, 1},
|
||||
{:mad, 0x4, 1},
|
||||
{:mdd, 0x8, 1},
|
||||
{:acc, 0x10, 1},
|
||||
{:eva, 0x20, 1},
|
||||
{:speed, 0x40, 1},
|
||||
{:stun, 0x80, 1},
|
||||
{:freeze, 0x100, 1},
|
||||
{:poison, 0x200, 1},
|
||||
{:seal, 0x400, 1},
|
||||
{:darkness, 0x800, 1},
|
||||
{:power_up, 0x1000, 1},
|
||||
{:magic_up, 0x2000, 1},
|
||||
{:p_guard_up, 0x4000, 1},
|
||||
{:m_guard_up, 0x8000, 1},
|
||||
{:doom, 0x10000, 1},
|
||||
{:web, 0x20000, 1},
|
||||
{:p_immune, 0x40000, 1},
|
||||
{:m_immune, 0x80000, 1},
|
||||
{:showdown, 0x100000, 1},
|
||||
{:hard_skin, 0x200000, 1},
|
||||
{:ambush, 0x400000, 1},
|
||||
{:damaged_elem_attr, 0x800000, 1},
|
||||
{:venom, 0x1000000, 1},
|
||||
{:blind, 0x2000000, 1},
|
||||
{:seal_skill, 0x4000000, 1},
|
||||
{:burned, 0x8000000, 1},
|
||||
{:dazzle, 0x10000000, 1},
|
||||
{:p_counter, 0x20000000, 1},
|
||||
{:m_counter, 0x40000000, 1},
|
||||
{:disable, 0x80000000, 1},
|
||||
|
||||
# Position 2 (second int)
|
||||
{:rise_by_toss, 0x1, 2},
|
||||
{:body_pressure, 0x2, 2},
|
||||
{:weakness, 0x4, 2},
|
||||
{:time_bomb, 0x8, 2},
|
||||
{:magic_crash, 0x10, 2},
|
||||
{:exchange_attack, 0x20, 2},
|
||||
{:heal_by_damage, 0x40, 2},
|
||||
{:invincible, 0x80, 2}
|
||||
]
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the bit value for a status effect.
|
||||
"""
|
||||
@spec get_bit(t()) :: integer()
|
||||
def get_bit(status) do
|
||||
case List.keyfind(all_statuses(), status, 0) do
|
||||
{_, bit, _} -> bit
|
||||
nil -> 0
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the position (1 or 2) for a status effect.
|
||||
"""
|
||||
@spec get_position(t()) :: integer()
|
||||
def get_position(status) do
|
||||
case List.keyfind(all_statuses(), status, 0) do
|
||||
{_, _, pos} -> pos
|
||||
nil -> 1
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if a status is in position 1.
|
||||
"""
|
||||
@spec position_1?(t()) :: boolean()
|
||||
def position_1?(status) do
|
||||
get_position(status) == 1
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if a status is in position 2.
|
||||
"""
|
||||
@spec position_2?(t()) :: boolean()
|
||||
def position_2?(status) do
|
||||
get_position(status) == 2
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets all statuses in position 1.
|
||||
"""
|
||||
@spec position_1_statuses() :: [t()]
|
||||
def position_1_statuses do
|
||||
all_statuses()
|
||||
|> Enum.filter(fn {_, _, pos} -> pos == 1 end)
|
||||
|> Enum.map(fn {status, _, _} -> status end)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets all statuses in position 2.
|
||||
"""
|
||||
@spec position_2_statuses() :: [t()]
|
||||
def position_2_statuses do
|
||||
all_statuses()
|
||||
|> Enum.filter(fn {_, _, pos} -> pos == 2 end)
|
||||
|> Enum.map(fn {status, _, _} -> status end)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Encodes a map of status effects to bitmasks.
|
||||
Returns {mask1, mask2} where each is an integer bitmask.
|
||||
"""
|
||||
@spec encode_statuses(%{t() => integer()}) :: {integer(), integer()}
|
||||
def encode_statuses(statuses) when is_map(statuses) do
|
||||
mask1 =
|
||||
statuses
|
||||
|> Enum.filter(fn {status, _} -> position_1?(status) end)
|
||||
|> Enum.reduce(0, fn {status, _}, acc -> acc ||| get_bit(status) end)
|
||||
|
||||
mask2 =
|
||||
statuses
|
||||
|> Enum.filter(fn {status, _} -> position_2?(status) end)
|
||||
|> Enum.reduce(0, fn {status, _}, acc -> acc ||| get_bit(status) end)
|
||||
|
||||
{mask1, mask2}
|
||||
end
|
||||
|
||||
def encode_statuses(_), do: {0, 0}
|
||||
|
||||
@doc """
|
||||
Decodes bitmasks to a list of status effects.
|
||||
"""
|
||||
@spec decode_statuses(integer(), integer()) :: [t()]
|
||||
def decode_statuses(mask1, mask2) do
|
||||
pos1 =
|
||||
position_1_statuses()
|
||||
|> Enum.filter(fn status -> (mask1 &&& get_bit(status)) != 0 end)
|
||||
|
||||
pos2 =
|
||||
position_2_statuses()
|
||||
|> Enum.filter(fn status -> (mask2 &&& get_bit(status)) != 0 end)
|
||||
|
||||
pos1 ++ pos2
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the linked disease for a monster status.
|
||||
Used when converting monster debuffs to player diseases.
|
||||
"""
|
||||
@spec get_linked_disease(t()) :: atom() | nil
|
||||
def get_linked_disease(status) do
|
||||
case status do
|
||||
:stun -> :stun
|
||||
:web -> :stun
|
||||
:poison -> :poison
|
||||
:venom -> :poison
|
||||
:seal -> :seal
|
||||
:magic_crash -> :seal
|
||||
:freeze -> :freeze
|
||||
:blind -> :darkness
|
||||
:speed -> :slow
|
||||
_ -> nil
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the monster status from a Pokemon-style skill ID.
|
||||
Used for familiar/capture card mechanics.
|
||||
"""
|
||||
@spec from_pokemon_skill(integer()) :: t() | nil
|
||||
def from_pokemon_skill(skill_id) do
|
||||
case skill_id do
|
||||
120 -> :seal
|
||||
121 -> :blind
|
||||
123 -> :stun
|
||||
125 -> :poison
|
||||
126 -> :speed
|
||||
137 -> :freeze
|
||||
_ -> nil
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if the status is a stun effect (prevents movement).
|
||||
"""
|
||||
@spec is_stun?(t()) :: boolean()
|
||||
def is_stun?(status) do
|
||||
status in [:stun, :freeze]
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if the status is a damage over time effect.
|
||||
"""
|
||||
@spec is_dot?(t()) :: boolean()
|
||||
def is_dot?(status) do
|
||||
status in [:poison, :venom, :burned]
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if the status is a debuff (negative effect).
|
||||
"""
|
||||
@spec is_debuff?(t()) :: boolean()
|
||||
def is_debuff?(status) do
|
||||
status in [
|
||||
:stun, :freeze, :poison, :seal, :darkness, :doom, :web,
|
||||
:blind, :seal_skill, :burned, :dazzle, :speed, :weakness,
|
||||
:time_bomb, :magic_crash, :disable, :rise_by_toss
|
||||
]
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if the status is a buff (positive effect).
|
||||
"""
|
||||
@spec is_buff?(t()) :: boolean()
|
||||
def is_buff?(status) do
|
||||
status in [
|
||||
:pad, :pdd, :mad, :mdd, :acc, :eva, :power_up, :magic_up,
|
||||
:p_guard_up, :m_guard_up, :hard_skin, :invincible, :heal_by_damage
|
||||
]
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the default duration for a status effect in milliseconds.
|
||||
"""
|
||||
@spec default_duration(t()) :: integer()
|
||||
def default_duration(status) do
|
||||
case status do
|
||||
:stun -> 3000
|
||||
:freeze -> 5000
|
||||
:poison -> 8000
|
||||
:seal -> 5000
|
||||
:darkness -> 8000
|
||||
:doom -> 10000
|
||||
:web -> 8000
|
||||
:blind -> 8000
|
||||
:speed -> 8000
|
||||
:weakness -> 8000
|
||||
_ -> 5000
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,7 +1,7 @@
|
||||
defmodule Odinsea.Game.Movement do
|
||||
@moduledoc """
|
||||
Movement parsing and validation for players, mobs, pets, summons, and dragons.
|
||||
Ported from Java MovementParse.java.
|
||||
Ported from Java MovementParse.java and all movement type classes.
|
||||
|
||||
Movement types (kind):
|
||||
- 1: Player
|
||||
@@ -9,138 +9,689 @@ defmodule Odinsea.Game.Movement do
|
||||
- 3: Pet
|
||||
- 4: Summon
|
||||
- 5: Dragon
|
||||
- 6: Familiar
|
||||
|
||||
This is a SIMPLIFIED implementation for now. The full Java version has complex
|
||||
parsing for different movement command types. We'll expand this as needed.
|
||||
Movement command types (40+ types):
|
||||
- 0, 37-42: Absolute movement (normal walking, flying)
|
||||
- 1, 2, 33, 34, 36: Relative movement (small adjustments)
|
||||
- 3, 4, 8, 100, 101: Teleport movement (rush, assassinate)
|
||||
- 5-7, 16-20: Mixed (teleport, aran, relative, bounce)
|
||||
- 9, 12: Chair movement
|
||||
- 10, 11: Stat change / equip special
|
||||
- 13, 14: Jump down (fall through platforms)
|
||||
- 15: Float (GMS vs non-GMS difference)
|
||||
- 21-31, 35: Aran combat step
|
||||
- 25-31: Special aran movements
|
||||
- 32: Unknown movement
|
||||
- -1: Bounce movement
|
||||
|
||||
Anti-cheat features:
|
||||
- Speed hack detection
|
||||
- High jump detection
|
||||
- Teleport validation
|
||||
- Movement count validation
|
||||
"""
|
||||
|
||||
require Logger
|
||||
alias Odinsea.Net.Packet.In
|
||||
alias Odinsea.Game.Character.Position
|
||||
alias Odinsea.Game.Movement.{Absolute, Relative, Teleport, JumpDown, Aran, Chair, Bounce, ChangeEquip, Unknown}
|
||||
|
||||
# Movement kind constants
|
||||
@kind_player 1
|
||||
@kind_mob 2
|
||||
@kind_pet 3
|
||||
@kind_summon 4
|
||||
@kind_dragon 5
|
||||
@kind_familiar 6
|
||||
@kind_android 7
|
||||
|
||||
# GMS flag - affects movement parsing
|
||||
@gms Application.compile_env(:odinsea, :gms, true)
|
||||
|
||||
@doc """
|
||||
Parses movement data from a packet.
|
||||
Returns {:ok, movements} or {:error, reason}.
|
||||
|
||||
For now, this returns a simplified structure. The full implementation
|
||||
would parse all movement fragment types.
|
||||
## Examples
|
||||
|
||||
iex> Movement.parse_movement(packet, 1) # Player movement
|
||||
{:ok, [%Absolute{command: 0, x: 100, y: 200, ...}, ...]}
|
||||
|
||||
"""
|
||||
def parse_movement(packet, _kind) do
|
||||
def parse_movement(packet, kind) do
|
||||
num_commands = In.decode_byte(packet)
|
||||
|
||||
# For now, just skip through the movement data and extract final position
|
||||
# TODO: Implement full movement parsing with all command types
|
||||
case extract_final_position(packet, num_commands) do
|
||||
{:ok, position} ->
|
||||
{:ok, %{num_commands: num_commands, final_position: position}}
|
||||
case parse_commands(packet, kind, num_commands, []) do
|
||||
{:ok, movements} when length(movements) == num_commands ->
|
||||
{:ok, Enum.reverse(movements)}
|
||||
|
||||
:error ->
|
||||
{:error, :invalid_movement}
|
||||
{:ok, _movements} ->
|
||||
{:error, :command_count_mismatch}
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, reason}
|
||||
end
|
||||
rescue
|
||||
e ->
|
||||
Logger.warning("Movement parse error: #{inspect(e)}")
|
||||
{:error, :parse_exception}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Updates an entity's position from movement data.
|
||||
Returns the final position and stance.
|
||||
"""
|
||||
def update_position(_movements, character_id) do
|
||||
# TODO: Implement position update logic
|
||||
# For now, just return ok
|
||||
Logger.debug("Update position for character #{character_id}")
|
||||
:ok
|
||||
def update_position(movements, current_position \\ %{x: 0, y: 0, stance: 0, foothold: 0}) do
|
||||
Enum.reduce(movements, {current_position, nil}, fn movement, {pos, _last_move} ->
|
||||
new_pos = extract_position(movement, pos)
|
||||
{new_pos, movement}
|
||||
end)
|
||||
|> elem(0)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Validates movement for anti-cheat purposes.
|
||||
Returns {:ok, validated_movements} or {:error, reason}.
|
||||
"""
|
||||
def validate_movement(movements, entity_type, options \\ []) do
|
||||
max_commands = Keyword.get(options, :max_commands, 100)
|
||||
max_distance = Keyword.get(options, :max_distance, 2000)
|
||||
start_pos = Keyword.get(options, :start_position, %{x: 0, y: 0})
|
||||
|
||||
cond do
|
||||
length(movements) > max_commands ->
|
||||
{:error, :too_many_commands}
|
||||
|
||||
length(movements) == 0 ->
|
||||
{:error, :no_movement}
|
||||
|
||||
exceeds_max_distance?(movements, start_pos, max_distance) ->
|
||||
{:error, :suspicious_distance}
|
||||
|
||||
contains_invalid_teleport?(movements, entity_type) ->
|
||||
{:error, :invalid_teleport}
|
||||
|
||||
true ->
|
||||
{:ok, movements}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Serializes a list of movements for packet output.
|
||||
"""
|
||||
def serialize_movements(movements) when is_list(movements) do
|
||||
count = length(movements)
|
||||
data = Enum.map_join(movements, &serialize/1)
|
||||
<<count::8, data::binary>>
|
||||
end
|
||||
|
||||
@doc """
|
||||
Serializes a single movement fragment.
|
||||
"""
|
||||
def serialize(%Absolute{} = m) do
|
||||
<<m.command::8,
|
||||
m.x::16-little, m.y::16-little,
|
||||
m.vx::16-little, m.vy::16-little,
|
||||
m.unk::16-little,
|
||||
m.offset_x::16-little, m.offset_y::16-little,
|
||||
m.stance::8,
|
||||
m.duration::16-little>>
|
||||
end
|
||||
|
||||
def serialize(%Relative{} = m) do
|
||||
<<m.command::8,
|
||||
m.x::16-little, m.y::16-little,
|
||||
m.stance::8,
|
||||
m.duration::16-little>>
|
||||
end
|
||||
|
||||
def serialize(%Teleport{} = m) do
|
||||
<<m.command::8,
|
||||
m.x::16-little, m.y::16-little,
|
||||
m.vx::16-little, m.vy::16-little,
|
||||
m.stance::8>>
|
||||
end
|
||||
|
||||
def serialize(%JumpDown{} = m) do
|
||||
<<m.command::8,
|
||||
m.x::16-little, m.y::16-little,
|
||||
m.vx::16-little, m.vy::16-little,
|
||||
m.unk::16-little,
|
||||
m.foothold::16-little,
|
||||
m.offset_x::16-little, m.offset_y::16-little,
|
||||
m.stance::8,
|
||||
m.duration::16-little>>
|
||||
end
|
||||
|
||||
def serialize(%Aran{} = m) do
|
||||
<<m.command::8,
|
||||
m.stance::8,
|
||||
m.unk::16-little>>
|
||||
end
|
||||
|
||||
def serialize(%Chair{} = m) do
|
||||
<<m.command::8,
|
||||
m.x::16-little, m.y::16-little,
|
||||
m.unk::16-little,
|
||||
m.stance::8,
|
||||
m.duration::16-little>>
|
||||
end
|
||||
|
||||
def serialize(%Bounce{} = m) do
|
||||
<<m.command::8,
|
||||
m.x::16-little, m.y::16-little,
|
||||
m.unk::16-little,
|
||||
m.foothold::16-little,
|
||||
m.stance::8,
|
||||
m.duration::16-little>>
|
||||
end
|
||||
|
||||
def serialize(%ChangeEquip{} = m) do
|
||||
<<m.command::8,
|
||||
m.wui::8>>
|
||||
end
|
||||
|
||||
def serialize(%Unknown{} = m) do
|
||||
<<m.command::8,
|
||||
m.unk::16-little,
|
||||
m.x::16-little, m.y::16-little,
|
||||
m.vx::16-little, m.vy::16-little,
|
||||
m.foothold::16-little,
|
||||
m.stance::8,
|
||||
m.duration::16-little>>
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Private Functions
|
||||
# ============================================================================
|
||||
|
||||
# Extract the final position from movement data
|
||||
# This is a TEMPORARY simplification - we just read through the movement
|
||||
# commands and try to extract the last absolute position
|
||||
defp extract_final_position(packet, num_commands) do
|
||||
try do
|
||||
final_pos = parse_commands(packet, num_commands, nil)
|
||||
{:ok, final_pos || %{x: 0, y: 0, stance: 0, foothold: 0}}
|
||||
rescue
|
||||
_ ->
|
||||
:error
|
||||
defp parse_commands(_packet, _kind, 0, acc), do: {:ok, acc}
|
||||
|
||||
defp parse_commands(packet, kind, remaining, acc) when remaining > 0 do
|
||||
command = In.decode_byte(packet)
|
||||
|
||||
case parse_command(packet, kind, command) do
|
||||
{:ok, movement} ->
|
||||
parse_commands(packet, kind, remaining - 1, [movement | acc])
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_commands(_packet, 0, last_position) do
|
||||
last_position
|
||||
# Bounce movement (-1)
|
||||
defp parse_command(packet, _kind, -1) do
|
||||
x = In.decode_short(packet)
|
||||
y = In.decode_short(packet)
|
||||
unk = In.decode_short(packet)
|
||||
fh = In.decode_short(packet)
|
||||
stance = In.decode_byte(packet)
|
||||
duration = In.decode_short(packet)
|
||||
|
||||
{:ok, %Bounce{
|
||||
command: -1,
|
||||
x: x,
|
||||
y: y,
|
||||
unk: unk,
|
||||
foothold: fh,
|
||||
stance: stance,
|
||||
duration: duration
|
||||
}}
|
||||
end
|
||||
|
||||
defp parse_commands(packet, remaining, last_position) do
|
||||
command = In.decode_byte(packet)
|
||||
# Absolute movement (0, 37-42) - Normal walk/fly
|
||||
defp parse_command(packet, _kind, command) when command in [0, 37, 38, 39, 40, 41, 42] do
|
||||
x = In.decode_short(packet)
|
||||
y = In.decode_short(packet)
|
||||
vx = In.decode_short(packet)
|
||||
vy = In.decode_short(packet)
|
||||
unk = In.decode_short(packet)
|
||||
offset_x = In.decode_short(packet)
|
||||
offset_y = In.decode_short(packet)
|
||||
stance = In.decode_byte(packet)
|
||||
duration = In.decode_short(packet)
|
||||
|
||||
new_position =
|
||||
case command do
|
||||
# Absolute movement commands - extract position
|
||||
cmd when cmd in [0, 37, 38, 39, 40, 41, 42] ->
|
||||
x = In.decode_short(packet)
|
||||
y = In.decode_short(packet)
|
||||
_xwobble = In.decode_short(packet)
|
||||
_ywobble = In.decode_short(packet)
|
||||
_unk = In.decode_short(packet)
|
||||
_xoffset = In.decode_short(packet)
|
||||
_yoffset = In.decode_short(packet)
|
||||
stance = In.decode_byte(packet)
|
||||
_duration = In.decode_short(packet)
|
||||
%{x: x, y: y, stance: stance, foothold: 0}
|
||||
|
||||
# Relative movement - skip for now
|
||||
cmd when cmd in [1, 2, 33, 34, 36] ->
|
||||
_xmod = In.decode_short(packet)
|
||||
_ymod = In.decode_short(packet)
|
||||
_stance = In.decode_byte(packet)
|
||||
_duration = In.decode_short(packet)
|
||||
last_position
|
||||
|
||||
# Teleport movement
|
||||
cmd when cmd in [3, 4, 8, 100, 101] ->
|
||||
x = In.decode_short(packet)
|
||||
y = In.decode_short(packet)
|
||||
_xwobble = In.decode_short(packet)
|
||||
_ywobble = In.decode_short(packet)
|
||||
stance = In.decode_byte(packet)
|
||||
%{x: x, y: y, stance: stance, foothold: 0}
|
||||
|
||||
# Chair movement
|
||||
cmd when cmd in [9, 12] ->
|
||||
x = In.decode_short(packet)
|
||||
y = In.decode_short(packet)
|
||||
_unk = In.decode_short(packet)
|
||||
stance = In.decode_byte(packet)
|
||||
_duration = In.decode_short(packet)
|
||||
%{x: x, y: y, stance: stance, foothold: 0}
|
||||
|
||||
# Aran combat step
|
||||
cmd when cmd in [21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 35] ->
|
||||
_stance = In.decode_byte(packet)
|
||||
_unk = In.decode_short(packet)
|
||||
last_position
|
||||
|
||||
# Jump down
|
||||
cmd when cmd in [13, 14] ->
|
||||
# Simplified - just skip the data
|
||||
x = In.decode_short(packet)
|
||||
y = In.decode_short(packet)
|
||||
_xwobble = In.decode_short(packet)
|
||||
_ywobble = In.decode_short(packet)
|
||||
_unk = In.decode_short(packet)
|
||||
_fh = In.decode_short(packet)
|
||||
_xoffset = In.decode_short(packet)
|
||||
_yoffset = In.decode_short(packet)
|
||||
stance = In.decode_byte(packet)
|
||||
_duration = In.decode_short(packet)
|
||||
%{x: x, y: y, stance: stance, foothold: 0}
|
||||
|
||||
# Unknown/unhandled - log and skip
|
||||
_ ->
|
||||
Logger.warning("Unhandled movement command: #{command}")
|
||||
last_position
|
||||
end
|
||||
|
||||
parse_commands(packet, remaining - 1, new_position || last_position)
|
||||
{:ok, %Absolute{
|
||||
command: command,
|
||||
x: x,
|
||||
y: y,
|
||||
vx: vx,
|
||||
vy: vy,
|
||||
unk: unk,
|
||||
offset_x: offset_x,
|
||||
offset_y: offset_y,
|
||||
stance: stance,
|
||||
duration: duration
|
||||
}}
|
||||
end
|
||||
|
||||
# Relative movement (1, 2, 33, 34, 36) - Small adjustments
|
||||
defp parse_command(packet, _kind, command) when command in [1, 2, 33, 34, 36] do
|
||||
xmod = In.decode_short(packet)
|
||||
ymod = In.decode_short(packet)
|
||||
stance = In.decode_byte(packet)
|
||||
duration = In.decode_short(packet)
|
||||
|
||||
{:ok, %Relative{
|
||||
command: command,
|
||||
x: xmod,
|
||||
y: ymod,
|
||||
stance: stance,
|
||||
duration: duration
|
||||
}}
|
||||
end
|
||||
|
||||
# Teleport movement (3, 4, 8, 100, 101) - Rush, assassinate, etc.
|
||||
defp parse_command(packet, _kind, command) when command in [3, 4, 8, 100, 101] do
|
||||
x = In.decode_short(packet)
|
||||
y = In.decode_short(packet)
|
||||
vx = In.decode_short(packet)
|
||||
vy = In.decode_short(packet)
|
||||
stance = In.decode_byte(packet)
|
||||
|
||||
{:ok, %Teleport{
|
||||
command: command,
|
||||
x: x,
|
||||
y: y,
|
||||
vx: vx,
|
||||
vy: vy,
|
||||
stance: stance
|
||||
}}
|
||||
end
|
||||
|
||||
# Complex cases 5-7, 16-20 with GMS/non-GMS differences
|
||||
defp parse_command(packet, _kind, command) when command in [5, 6, 7, 16, 17, 18, 19, 20] do
|
||||
cond do
|
||||
# Bounce movement variants
|
||||
(@gms && command == 19) || (!@gms && command == 18) ->
|
||||
x = In.decode_short(packet)
|
||||
y = In.decode_short(packet)
|
||||
unk = In.decode_short(packet)
|
||||
fh = In.decode_short(packet)
|
||||
stance = In.decode_byte(packet)
|
||||
duration = In.decode_short(packet)
|
||||
|
||||
{:ok, %Bounce{
|
||||
command: command,
|
||||
x: x,
|
||||
y: y,
|
||||
unk: unk,
|
||||
foothold: fh,
|
||||
stance: stance,
|
||||
duration: duration
|
||||
}}
|
||||
|
||||
# Aran movement
|
||||
(@gms && command == 17) || (!@gms && command == 16) || (!@gms && command == 20) ->
|
||||
stance = In.decode_byte(packet)
|
||||
unk = In.decode_short(packet)
|
||||
|
||||
{:ok, %Aran{
|
||||
command: command,
|
||||
stance: stance,
|
||||
unk: unk
|
||||
}}
|
||||
|
||||
# Relative movement
|
||||
(@gms && command == 20) || (!@gms && command == 19) ||
|
||||
(@gms && command == 18) || (!@gms && command == 17) ->
|
||||
xmod = In.decode_short(packet)
|
||||
ymod = In.decode_short(packet)
|
||||
stance = In.decode_byte(packet)
|
||||
duration = In.decode_short(packet)
|
||||
|
||||
{:ok, %Relative{
|
||||
command: command,
|
||||
x: xmod,
|
||||
y: ymod,
|
||||
stance: stance,
|
||||
duration: duration
|
||||
}}
|
||||
|
||||
# Teleport movement variants
|
||||
(!@gms && command == 5) || (!@gms && command == 7) || (@gms && command == 6) ->
|
||||
x = In.decode_short(packet)
|
||||
y = In.decode_short(packet)
|
||||
vx = In.decode_short(packet)
|
||||
vy = In.decode_short(packet)
|
||||
stance = In.decode_byte(packet)
|
||||
|
||||
{:ok, %Teleport{
|
||||
command: command,
|
||||
x: x,
|
||||
y: y,
|
||||
vx: vx,
|
||||
vy: vy,
|
||||
stance: stance
|
||||
}}
|
||||
|
||||
# Default to absolute movement
|
||||
true ->
|
||||
x = In.decode_short(packet)
|
||||
y = In.decode_short(packet)
|
||||
vx = In.decode_short(packet)
|
||||
vy = In.decode_short(packet)
|
||||
unk = In.decode_short(packet)
|
||||
offset_x = In.decode_short(packet)
|
||||
offset_y = In.decode_short(packet)
|
||||
stance = In.decode_byte(packet)
|
||||
duration = In.decode_short(packet)
|
||||
|
||||
{:ok, %Absolute{
|
||||
command: command,
|
||||
x: x,
|
||||
y: y,
|
||||
vx: vx,
|
||||
vy: vy,
|
||||
unk: unk,
|
||||
offset_x: offset_x,
|
||||
offset_y: offset_y,
|
||||
stance: stance,
|
||||
duration: duration
|
||||
}}
|
||||
end
|
||||
end
|
||||
|
||||
# Chair movement (9, 12)
|
||||
defp parse_command(packet, _kind, command) when command in [9, 12] do
|
||||
x = In.decode_short(packet)
|
||||
y = In.decode_short(packet)
|
||||
unk = In.decode_short(packet)
|
||||
stance = In.decode_byte(packet)
|
||||
duration = In.decode_short(packet)
|
||||
|
||||
{:ok, %Chair{
|
||||
command: command,
|
||||
x: x,
|
||||
y: y,
|
||||
unk: unk,
|
||||
stance: stance,
|
||||
duration: duration
|
||||
}}
|
||||
end
|
||||
|
||||
# Chair (10, 11) - GMS vs non-GMS differences
|
||||
defp parse_command(packet, _kind, command) when command in [10, 11] do
|
||||
if (@gms && command == 10) || (!@gms && command == 11) do
|
||||
x = In.decode_short(packet)
|
||||
y = In.decode_short(packet)
|
||||
unk = In.decode_short(packet)
|
||||
stance = In.decode_byte(packet)
|
||||
duration = In.decode_short(packet)
|
||||
|
||||
{:ok, %Chair{
|
||||
command: command,
|
||||
x: x,
|
||||
y: y,
|
||||
unk: unk,
|
||||
stance: stance,
|
||||
duration: duration
|
||||
}}
|
||||
else
|
||||
wui = In.decode_byte(packet)
|
||||
|
||||
{:ok, %ChangeEquip{
|
||||
command: command,
|
||||
wui: wui
|
||||
}}
|
||||
end
|
||||
end
|
||||
|
||||
# Aran combat step (21-31, 35)
|
||||
defp parse_command(packet, _kind, command) when command in [21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 35] do
|
||||
stance = In.decode_byte(packet)
|
||||
unk = In.decode_short(packet)
|
||||
|
||||
{:ok, %Aran{
|
||||
command: command,
|
||||
stance: stance,
|
||||
unk: unk
|
||||
}}
|
||||
end
|
||||
|
||||
# Jump down (13, 14) - with GMS/non-GMS differences
|
||||
defp parse_command(packet, _kind, command) when command in [13, 14] do
|
||||
cond do
|
||||
# Full jump down movement
|
||||
(@gms && command == 14) || (!@gms && command == 13) ->
|
||||
x = In.decode_short(packet)
|
||||
y = In.decode_short(packet)
|
||||
vx = In.decode_short(packet)
|
||||
vy = In.decode_short(packet)
|
||||
unk = In.decode_short(packet)
|
||||
fh = In.decode_short(packet)
|
||||
offset_x = In.decode_short(packet)
|
||||
offset_y = In.decode_short(packet)
|
||||
stance = In.decode_byte(packet)
|
||||
duration = In.decode_short(packet)
|
||||
|
||||
{:ok, %JumpDown{
|
||||
command: command,
|
||||
x: x,
|
||||
y: y,
|
||||
vx: vx,
|
||||
vy: vy,
|
||||
unk: unk,
|
||||
foothold: fh,
|
||||
offset_x: offset_x,
|
||||
offset_y: offset_y,
|
||||
stance: stance,
|
||||
duration: duration
|
||||
}}
|
||||
|
||||
# GMS chair movement
|
||||
@gms && command == 13 ->
|
||||
x = In.decode_short(packet)
|
||||
y = In.decode_short(packet)
|
||||
unk = In.decode_short(packet)
|
||||
stance = In.decode_byte(packet)
|
||||
duration = In.decode_short(packet)
|
||||
|
||||
{:ok, %Chair{
|
||||
command: command,
|
||||
x: x,
|
||||
y: y,
|
||||
unk: unk,
|
||||
stance: stance,
|
||||
duration: duration
|
||||
}}
|
||||
|
||||
# Default to relative
|
||||
true ->
|
||||
xmod = In.decode_short(packet)
|
||||
ymod = In.decode_short(packet)
|
||||
stance = In.decode_byte(packet)
|
||||
duration = In.decode_short(packet)
|
||||
|
||||
{:ok, %Relative{
|
||||
command: command,
|
||||
x: xmod,
|
||||
y: ymod,
|
||||
stance: stance,
|
||||
duration: duration
|
||||
}}
|
||||
end
|
||||
end
|
||||
|
||||
# Float (15) - GMS vs non-GMS
|
||||
defp parse_command(packet, _kind, 15) do
|
||||
if @gms do
|
||||
xmod = In.decode_short(packet)
|
||||
ymod = In.decode_short(packet)
|
||||
stance = In.decode_byte(packet)
|
||||
duration = In.decode_short(packet)
|
||||
|
||||
{:ok, %Relative{
|
||||
command: 15,
|
||||
x: xmod,
|
||||
y: ymod,
|
||||
stance: stance,
|
||||
duration: duration
|
||||
}}
|
||||
else
|
||||
x = In.decode_short(packet)
|
||||
y = In.decode_short(packet)
|
||||
vx = In.decode_short(packet)
|
||||
vy = In.decode_short(packet)
|
||||
unk = In.decode_short(packet)
|
||||
offset_x = In.decode_short(packet)
|
||||
offset_y = In.decode_short(packet)
|
||||
stance = In.decode_byte(packet)
|
||||
duration = In.decode_short(packet)
|
||||
|
||||
{:ok, %Absolute{
|
||||
command: 15,
|
||||
x: x,
|
||||
y: y,
|
||||
vx: vx,
|
||||
vy: vy,
|
||||
unk: unk,
|
||||
offset_x: offset_x,
|
||||
offset_y: offset_y,
|
||||
stance: stance,
|
||||
duration: duration
|
||||
}}
|
||||
end
|
||||
end
|
||||
|
||||
# Unknown movement (32)
|
||||
defp parse_command(packet, _kind, 32) do
|
||||
unk = In.decode_short(packet)
|
||||
x = In.decode_short(packet)
|
||||
y = In.decode_short(packet)
|
||||
vx = In.decode_short(packet)
|
||||
vy = In.decode_short(packet)
|
||||
fh = In.decode_short(packet)
|
||||
stance = In.decode_byte(packet)
|
||||
duration = In.decode_short(packet)
|
||||
|
||||
{:ok, %Unknown{
|
||||
command: 32,
|
||||
unk: unk,
|
||||
x: x,
|
||||
y: y,
|
||||
vx: vx,
|
||||
vy: vy,
|
||||
foothold: fh,
|
||||
stance: stance,
|
||||
duration: duration
|
||||
}}
|
||||
end
|
||||
|
||||
# Unknown command type
|
||||
defp parse_command(_packet, kind, command) do
|
||||
Logger.warning("Unknown movement command: kind=#{kind}, command=#{command}")
|
||||
{:error, {:unknown_command, command}}
|
||||
end
|
||||
|
||||
# Extract position from different movement types
|
||||
defp extract_position(%{x: x, y: y, stance: stance} = movement, _current_pos) do
|
||||
fh = Map.get(movement, :foothold, 0)
|
||||
%{x: x, y: y, stance: stance, foothold: fh}
|
||||
end
|
||||
|
||||
defp extract_position(_movement, current_pos), do: current_pos
|
||||
|
||||
# Anti-cheat validation helpers
|
||||
defp exceeds_max_distance?(movements, start_pos, max_distance) do
|
||||
final_pos = update_position(movements, start_pos)
|
||||
dx = final_pos.x - start_pos.x
|
||||
dy = final_pos.y - start_pos.y
|
||||
distance_sq = dx * dx + dy * dy
|
||||
distance_sq > max_distance * max_distance
|
||||
end
|
||||
|
||||
defp contains_invalid_teleport?(movements, entity_type) do
|
||||
# Only certain entities should be able to teleport
|
||||
allowed_teleport = entity_type in [:player, :mob]
|
||||
|
||||
if allowed_teleport do
|
||||
# Check for suspicious teleport patterns
|
||||
teleport_count = Enum.count(movements, fn m -> is_struct(m, Teleport) end)
|
||||
# Too many teleports in one movement packet is suspicious
|
||||
teleport_count > 5
|
||||
else
|
||||
# Non-allowed entities shouldn't teleport at all
|
||||
Enum.any?(movements, fn m -> is_struct(m, Teleport) end)
|
||||
end
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Public API for Handler Integration
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Parses and validates player movement.
|
||||
Returns {:ok, movements, final_position} or {:error, reason}.
|
||||
"""
|
||||
def parse_player_movement(packet, start_position \\ %{x: 0, y: 0, stance: 0, foothold: 0}) do
|
||||
with {:ok, movements} <- parse_movement(packet, @kind_player),
|
||||
{:ok, validated} <- validate_movement(movements, :player, start_position: start_position),
|
||||
final_pos <- update_position(validated, start_position) do
|
||||
{:ok, validated, final_pos}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Parses and validates mob movement.
|
||||
"""
|
||||
def parse_mob_movement(packet, start_position \\ %{x: 0, y: 0, stance: 0, foothold: 0}) do
|
||||
with {:ok, movements} <- parse_movement(packet, @kind_mob),
|
||||
{:ok, validated} <- validate_movement(movements, :mob, start_position: start_position),
|
||||
final_pos <- update_position(validated, start_position) do
|
||||
{:ok, validated, final_pos}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Parses and validates pet movement.
|
||||
"""
|
||||
def parse_pet_movement(packet, start_position \\ %{x: 0, y: 0, stance: 0, foothold: 0}) do
|
||||
with {:ok, movements} <- parse_movement(packet, @kind_pet),
|
||||
final_pos <- update_position(movements, start_position) do
|
||||
{:ok, movements, final_pos}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Parses and validates summon movement.
|
||||
"""
|
||||
def parse_summon_movement(packet, start_position \\ %{x: 0, y: 0, stance: 0, foothold: 0}) do
|
||||
with {:ok, movements} <- parse_movement(packet, @kind_summon),
|
||||
final_pos <- update_position(movements, start_position) do
|
||||
{:ok, movements, final_pos}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Parses and validates familiar movement.
|
||||
"""
|
||||
def parse_familiar_movement(packet, start_position \\ %{x: 0, y: 0, stance: 0, foothold: 0}) do
|
||||
with {:ok, movements} <- parse_movement(packet, @kind_familiar),
|
||||
final_pos <- update_position(movements, start_position) do
|
||||
{:ok, movements, final_pos}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Parses and validates android movement.
|
||||
"""
|
||||
def parse_android_movement(packet, start_position \\ %{x: 0, y: 0, stance: 0, foothold: 0}) do
|
||||
with {:ok, movements} <- parse_movement(packet, @kind_android),
|
||||
final_pos <- update_position(movements, start_position) do
|
||||
{:ok, movements, final_pos}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the kind value for an entity type atom.
|
||||
"""
|
||||
def kind_for(:player), do: @kind_player
|
||||
def kind_for(:mob), do: @kind_mob
|
||||
def kind_for(:pet), do: @kind_pet
|
||||
def kind_for(:summon), do: @kind_summon
|
||||
def kind_for(:dragon), do: @kind_dragon
|
||||
def kind_for(:familiar), do: @kind_familiar
|
||||
def kind_for(:android), do: @kind_android
|
||||
def kind_for(_), do: @kind_player
|
||||
end
|
||||
|
||||
39
lib/odinsea/game/movement/absolute.ex
Normal file
39
lib/odinsea/game/movement/absolute.ex
Normal file
@@ -0,0 +1,39 @@
|
||||
defmodule Odinsea.Game.Movement.Absolute do
|
||||
@moduledoc """
|
||||
Absolute life movement - normal walking, flying, etc.
|
||||
Ported from Java AbsoluteLifeMovement.java
|
||||
|
||||
This is the most common movement type for:
|
||||
- Normal walking (command 0)
|
||||
- Flying (commands 37-42)
|
||||
- Rush skills (when not instant)
|
||||
|
||||
Contains position, velocity, and offset information.
|
||||
"""
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
command: integer(), # Movement command type (0, 37-42)
|
||||
x: integer(), # Target X position
|
||||
y: integer(), # Target Y position
|
||||
vx: integer(), # X velocity (pixels per second)
|
||||
vy: integer(), # Y velocity (pixels per second)
|
||||
unk: integer(), # Unknown short value
|
||||
offset_x: integer(), # X offset
|
||||
offset_y: integer(), # Y offset
|
||||
stance: integer(), # New stance/move action
|
||||
duration: integer() # Movement duration in ms
|
||||
}
|
||||
|
||||
defstruct [
|
||||
:command,
|
||||
:x,
|
||||
:y,
|
||||
:vx,
|
||||
:vy,
|
||||
:unk,
|
||||
:offset_x,
|
||||
:offset_y,
|
||||
:stance,
|
||||
:duration
|
||||
]
|
||||
end
|
||||
24
lib/odinsea/game/movement/aran.ex
Normal file
24
lib/odinsea/game/movement/aran.ex
Normal file
@@ -0,0 +1,24 @@
|
||||
defmodule Odinsea.Game.Movement.Aran do
|
||||
@moduledoc """
|
||||
Aran movement - Aran class combat step movements.
|
||||
Ported from Java AranMovement.java
|
||||
|
||||
Used for:
|
||||
- Aran combat steps (commands 21-31)
|
||||
- Special Aran skills (command 35)
|
||||
|
||||
Note: Position is not used for this movement type.
|
||||
"""
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
command: integer(), # Movement command type (21-31, 35)
|
||||
stance: integer(), # New stance/move action
|
||||
unk: integer() # Unknown short value
|
||||
}
|
||||
|
||||
defstruct [
|
||||
:command,
|
||||
:stance,
|
||||
:unk
|
||||
]
|
||||
end
|
||||
31
lib/odinsea/game/movement/bounce.ex
Normal file
31
lib/odinsea/game/movement/bounce.ex
Normal file
@@ -0,0 +1,31 @@
|
||||
defmodule Odinsea.Game.Movement.Bounce do
|
||||
@moduledoc """
|
||||
Bounce movement - bouncing off surfaces.
|
||||
Ported from Java BounceMovement.java
|
||||
|
||||
Used for:
|
||||
- Bouncing (command -1)
|
||||
- Wall bouncing (commands 18, 19)
|
||||
- Platform bouncing (commands 5-7)
|
||||
"""
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
command: integer(), # Movement command type (-1, 5-7, 18, 19)
|
||||
x: integer(), # Bounce X position
|
||||
y: integer(), # Bounce Y position
|
||||
unk: integer(), # Unknown short value
|
||||
foothold: integer(), # Foothold after bounce
|
||||
stance: integer(), # New stance/move action
|
||||
duration: integer() # Movement duration in ms
|
||||
}
|
||||
|
||||
defstruct [
|
||||
:command,
|
||||
:x,
|
||||
:y,
|
||||
:unk,
|
||||
:foothold,
|
||||
:stance,
|
||||
:duration
|
||||
]
|
||||
end
|
||||
29
lib/odinsea/game/movement/chair.ex
Normal file
29
lib/odinsea/game/movement/chair.ex
Normal file
@@ -0,0 +1,29 @@
|
||||
defmodule Odinsea.Game.Movement.Chair do
|
||||
@moduledoc """
|
||||
Chair movement - sitting on chairs/mounts.
|
||||
Ported from Java ChairMovement.java
|
||||
|
||||
Used for:
|
||||
- Sitting on chairs (commands 9, 12)
|
||||
- Mount riding (command 13 in GMS)
|
||||
- Special seating
|
||||
"""
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
command: integer(), # Movement command type (9, 10, 11, 12, 13)
|
||||
x: integer(), # Chair X position
|
||||
y: integer(), # Chair Y position
|
||||
unk: integer(), # Unknown short value
|
||||
stance: integer(), # New stance/move action
|
||||
duration: integer() # Movement duration in ms
|
||||
}
|
||||
|
||||
defstruct [
|
||||
:command,
|
||||
:x,
|
||||
:y,
|
||||
:unk,
|
||||
:stance,
|
||||
:duration
|
||||
]
|
||||
end
|
||||
22
lib/odinsea/game/movement/change_equip.ex
Normal file
22
lib/odinsea/game/movement/change_equip.ex
Normal file
@@ -0,0 +1,22 @@
|
||||
defmodule Odinsea.Game.Movement.ChangeEquip do
|
||||
@moduledoc """
|
||||
Change equip special awesome - equipment change during movement.
|
||||
Ported from Java ChangeEquipSpecialAwesome.java
|
||||
|
||||
Used for:
|
||||
- Changing equipment mid-movement (commands 10, 11)
|
||||
- Quick gear switching
|
||||
|
||||
Note: Position is always 0,0 for this fragment type.
|
||||
"""
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
command: integer(), # Movement command type (10, 11)
|
||||
wui: integer() # Weapon upgrade index or similar
|
||||
}
|
||||
|
||||
defstruct [
|
||||
:command,
|
||||
:wui
|
||||
]
|
||||
end
|
||||
40
lib/odinsea/game/movement/jump_down.ex
Normal file
40
lib/odinsea/game/movement/jump_down.ex
Normal file
@@ -0,0 +1,40 @@
|
||||
defmodule Odinsea.Game.Movement.JumpDown do
|
||||
@moduledoc """
|
||||
Jump down movement - falling through platforms.
|
||||
Ported from Java JumpDownMovement.java
|
||||
|
||||
Used for:
|
||||
- Jumping down through platforms (commands 13, 14)
|
||||
- Controlled falling
|
||||
|
||||
Contains foothold information for landing detection.
|
||||
"""
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
command: integer(), # Movement command type (13, 14)
|
||||
x: integer(), # Target X position
|
||||
y: integer(), # Target Y position
|
||||
vx: integer(), # X velocity
|
||||
vy: integer(), # Y velocity
|
||||
unk: integer(), # Unknown short value
|
||||
foothold: integer(), # Target foothold ID
|
||||
offset_x: integer(), # X offset
|
||||
offset_y: integer(), # Y offset
|
||||
stance: integer(), # New stance/move action
|
||||
duration: integer() # Movement duration in ms
|
||||
}
|
||||
|
||||
defstruct [
|
||||
:command,
|
||||
:x,
|
||||
:y,
|
||||
:vx,
|
||||
:vy,
|
||||
:unk,
|
||||
:foothold,
|
||||
:offset_x,
|
||||
:offset_y,
|
||||
:stance,
|
||||
:duration
|
||||
]
|
||||
end
|
||||
443
lib/odinsea/game/movement/path.ex
Normal file
443
lib/odinsea/game/movement/path.ex
Normal file
@@ -0,0 +1,443 @@
|
||||
defmodule Odinsea.Game.Movement.Path do
|
||||
@moduledoc """
|
||||
MovePath for mob movement (newer movement system).
|
||||
Ported from Java MovePath.java
|
||||
|
||||
This is an alternative movement system used by mobs in newer
|
||||
versions of MapleStory. It uses a more compact encoding.
|
||||
|
||||
Structure:
|
||||
- Initial position (x, y, vx, vy)
|
||||
- List of movement elements
|
||||
- Optional passive data (keypad states, movement rect)
|
||||
"""
|
||||
|
||||
import Bitwise
|
||||
alias Odinsea.Net.Packet.In
|
||||
|
||||
defstruct [
|
||||
:x, # Initial X position
|
||||
:y, # Initial Y position
|
||||
:vx, # Initial X velocity
|
||||
:vy, # Initial Y velocity
|
||||
elements: [], # List of MoveElem
|
||||
key_pad_states: [], # Keypad states (passive mode)
|
||||
move_rect: nil # Movement rectangle (passive mode)
|
||||
]
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
x: integer() | nil,
|
||||
y: integer() | nil,
|
||||
vx: integer() | nil,
|
||||
vy: integer() | nil,
|
||||
elements: list(MoveElem.t()),
|
||||
key_pad_states: list(integer()),
|
||||
move_rect: map() | nil
|
||||
}
|
||||
|
||||
defmodule MoveElem do
|
||||
@moduledoc """
|
||||
Individual movement element within a MovePath.
|
||||
"""
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
attribute: integer(), # Movement type/attribute
|
||||
x: integer(), # X position
|
||||
y: integer(), # Y position
|
||||
vx: integer(), # X velocity
|
||||
vy: integer(), # Y velocity
|
||||
fh: integer(), # Foothold
|
||||
fall_start: integer(), # Fall start position
|
||||
offset_x: integer(), # X offset
|
||||
offset_y: integer(), # Y offset
|
||||
sn: integer(), # Skill/stat number
|
||||
move_action: integer(), # Move action/stance
|
||||
elapse: integer() # Elapsed time
|
||||
}
|
||||
|
||||
defstruct [
|
||||
:attribute,
|
||||
:x,
|
||||
:y,
|
||||
:vx,
|
||||
:vy,
|
||||
:fh,
|
||||
:fall_start,
|
||||
:offset_x,
|
||||
:offset_y,
|
||||
:sn,
|
||||
:move_action,
|
||||
:elapse
|
||||
]
|
||||
end
|
||||
|
||||
@doc """
|
||||
Decodes a MovePath from a packet.
|
||||
|
||||
## Parameters
|
||||
- packet: The incoming packet
|
||||
- passive: Whether to decode passive data (keypad, rect)
|
||||
|
||||
## Returns
|
||||
%MovePath{} struct with decoded data
|
||||
"""
|
||||
def decode(packet, passive \\ false) do
|
||||
old_x = In.decode_short(packet)
|
||||
old_y = In.decode_short(packet)
|
||||
old_vx = In.decode_short(packet)
|
||||
old_vy = In.decode_short(packet)
|
||||
|
||||
count = In.decode_byte(packet)
|
||||
|
||||
{elements, final_x, final_y, final_vx, final_vy, _fh_last} =
|
||||
decode_elements(packet, count, old_x, old_y, old_vx, old_vy, [])
|
||||
|
||||
path = %__MODULE__{
|
||||
x: old_x,
|
||||
y: old_y,
|
||||
vx: old_vx,
|
||||
vy: old_vy,
|
||||
elements: Enum.reverse(elements)
|
||||
}
|
||||
|
||||
if passive do
|
||||
{key_pad_states, move_rect} = decode_passive_data(packet)
|
||||
%{path |
|
||||
x: final_x,
|
||||
y: final_y,
|
||||
vx: final_vx,
|
||||
vy: final_vy,
|
||||
key_pad_states: key_pad_states,
|
||||
move_rect: move_rect
|
||||
}
|
||||
else
|
||||
%{path |
|
||||
x: final_x,
|
||||
y: final_y,
|
||||
vx: final_vx,
|
||||
vy: final_vy
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Encodes a MovePath to binary for packet output.
|
||||
"""
|
||||
def encode(%__MODULE__{} = path, _passive \\ false) do
|
||||
elements_data = Enum.map_join(path.elements, &encode_element/1)
|
||||
|
||||
<<path.x::16-little, path.y::16-little,
|
||||
path.vx::16-little, path.vy::16-little,
|
||||
length(path.elements)::8,
|
||||
elements_data::binary>>
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the final position from the MovePath.
|
||||
"""
|
||||
def get_final_position(%__MODULE__{} = path) do
|
||||
case List.last(path.elements) do
|
||||
nil -> %{x: path.x, y: path.y}
|
||||
elem -> %{x: elem.x, y: elem.y}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the final move action/stance from the MovePath.
|
||||
"""
|
||||
def get_final_action(%__MODULE__{} = path) do
|
||||
case List.last(path.elements) do
|
||||
nil -> 0
|
||||
elem -> elem.move_action
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the final foothold from the MovePath.
|
||||
"""
|
||||
def get_final_foothold(%__MODULE__{} = path) do
|
||||
case List.last(path.elements) do
|
||||
nil -> 0
|
||||
elem -> elem.fh
|
||||
end
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Private Functions
|
||||
# ============================================================================
|
||||
|
||||
defp decode_elements(_packet, 0, old_x, old_y, old_vx, old_vy, acc),
|
||||
do: {acc, old_x, old_y, old_vx, old_vy, 0}
|
||||
|
||||
defp decode_elements(packet, count, old_x, old_y, old_vx, old_vy, acc) do
|
||||
attr = In.decode_byte(packet)
|
||||
|
||||
{elem, new_x, new_y, new_vx, new_vy, _fh_last} =
|
||||
case attr do
|
||||
# Absolute with foothold
|
||||
a when a in [0, 6, 13, 15, 37, 38] ->
|
||||
x = In.decode_short(packet)
|
||||
y = In.decode_short(packet)
|
||||
vx = In.decode_short(packet)
|
||||
vy = In.decode_short(packet)
|
||||
fh = In.decode_short(packet)
|
||||
|
||||
fall_start = if attr == 13, do: In.decode_short(packet), else: 0
|
||||
offset_x = In.decode_short(packet)
|
||||
offset_y = In.decode_short(packet)
|
||||
|
||||
elem = %MoveElem{
|
||||
attribute: attr,
|
||||
x: x,
|
||||
y: y,
|
||||
vx: vx,
|
||||
vy: vy,
|
||||
fh: fh,
|
||||
fall_start: fall_start,
|
||||
offset_x: offset_x,
|
||||
offset_y: offset_y
|
||||
}
|
||||
{elem, x, y, vx, vy, fh}
|
||||
|
||||
# Velocity only
|
||||
a when a in [1, 2, 14, 17, 19, 32, 33, 34, 35] ->
|
||||
vx = In.decode_short(packet)
|
||||
vy = In.decode_short(packet)
|
||||
|
||||
elem = %MoveElem{
|
||||
attribute: attr,
|
||||
x: old_x,
|
||||
y: old_y,
|
||||
vx: vx,
|
||||
vy: vy,
|
||||
fh: 0
|
||||
}
|
||||
{elem, old_x, old_y, vx, vy, 0}
|
||||
|
||||
# Position with foothold
|
||||
a when a in [3, 4, 5, 7, 8, 9, 11] ->
|
||||
x = In.decode_short(packet)
|
||||
y = In.decode_short(packet)
|
||||
fh = In.decode_short(packet)
|
||||
|
||||
elem = %MoveElem{
|
||||
attribute: attr,
|
||||
x: x,
|
||||
y: y,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
fh: fh
|
||||
}
|
||||
{elem, x, y, 0, 0, fh}
|
||||
|
||||
# Stat change
|
||||
10 ->
|
||||
sn = In.decode_byte(packet)
|
||||
|
||||
elem = %MoveElem{
|
||||
attribute: attr,
|
||||
sn: sn,
|
||||
x: old_x,
|
||||
y: old_y,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
fh: 0,
|
||||
elapse: 0,
|
||||
move_action: 0
|
||||
}
|
||||
{elem, old_x, old_y, 0, 0, 0}
|
||||
|
||||
# Start fall down
|
||||
12 ->
|
||||
vx = In.decode_short(packet)
|
||||
vy = In.decode_short(packet)
|
||||
fall_start = In.decode_short(packet)
|
||||
|
||||
elem = %MoveElem{
|
||||
attribute: attr,
|
||||
x: old_x,
|
||||
y: old_y,
|
||||
vx: vx,
|
||||
vy: vy,
|
||||
fh: 0,
|
||||
fall_start: fall_start
|
||||
}
|
||||
{elem, old_x, old_y, vx, vy, 0}
|
||||
|
||||
# Flying block
|
||||
18 ->
|
||||
x = In.decode_short(packet)
|
||||
y = In.decode_short(packet)
|
||||
vx = In.decode_short(packet)
|
||||
vy = In.decode_short(packet)
|
||||
|
||||
elem = %MoveElem{
|
||||
attribute: attr,
|
||||
x: x,
|
||||
y: y,
|
||||
vx: vx,
|
||||
vy: vy,
|
||||
fh: 0
|
||||
}
|
||||
{elem, x, y, vx, vy, 0}
|
||||
|
||||
# No change (21-31)
|
||||
a when a in [21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31] ->
|
||||
elem = %MoveElem{
|
||||
attribute: attr,
|
||||
x: old_x,
|
||||
y: old_y,
|
||||
vx: old_vx,
|
||||
vy: old_vy,
|
||||
fh: 0
|
||||
}
|
||||
{elem, old_x, old_y, old_vx, old_vy, 0}
|
||||
|
||||
# Special case 36
|
||||
36 ->
|
||||
x = In.decode_short(packet)
|
||||
y = In.decode_short(packet)
|
||||
vx = In.decode_short(packet)
|
||||
vy = In.decode_short(packet)
|
||||
fh = In.decode_short(packet)
|
||||
|
||||
elem = %MoveElem{
|
||||
attribute: attr,
|
||||
x: x,
|
||||
y: y,
|
||||
vx: vx,
|
||||
vy: vy,
|
||||
fh: fh
|
||||
}
|
||||
{elem, x, y, vx, vy, fh}
|
||||
|
||||
# Unknown attribute - skip gracefully
|
||||
_unknown ->
|
||||
elem = %MoveElem{
|
||||
attribute: attr,
|
||||
x: old_x,
|
||||
y: old_y,
|
||||
vx: old_vx,
|
||||
vy: old_vy,
|
||||
fh: 0
|
||||
}
|
||||
{elem, old_x, old_y, old_vx, old_vy, 0}
|
||||
end
|
||||
|
||||
# Read move action and elapse (except for stat change)
|
||||
{elem, new_x, new_y, new_vx, new_vy} =
|
||||
if attr != 10 do
|
||||
move_action = In.decode_byte(packet)
|
||||
elapse = In.decode_short(packet)
|
||||
|
||||
{%{elem |
|
||||
move_action: move_action,
|
||||
elapse: elapse
|
||||
}, elem.x, elem.y, elem.vx, elem.vy}
|
||||
else
|
||||
{elem, new_x, new_y, new_vx, new_vy}
|
||||
end
|
||||
|
||||
decode_elements(
|
||||
packet,
|
||||
count - 1,
|
||||
new_x,
|
||||
new_y,
|
||||
new_vx,
|
||||
new_vy,
|
||||
[elem | acc]
|
||||
)
|
||||
end
|
||||
|
||||
defp decode_passive_data(packet) do
|
||||
keys = In.decode_byte(packet)
|
||||
|
||||
key_pad_states =
|
||||
if keys > 0 do
|
||||
decode_keypad_states(packet, keys, 0, [])
|
||||
else
|
||||
[]
|
||||
end
|
||||
|
||||
move_rect = %{
|
||||
left: In.decode_short(packet),
|
||||
top: In.decode_short(packet),
|
||||
right: In.decode_short(packet),
|
||||
bottom: In.decode_short(packet)
|
||||
}
|
||||
|
||||
{Enum.reverse(key_pad_states), move_rect}
|
||||
end
|
||||
|
||||
defp decode_keypad_states(_packet, 0, _value, acc), do: acc
|
||||
|
||||
defp decode_keypad_states(packet, remaining, value, acc) do
|
||||
{new_value, decoded} =
|
||||
if rem(length(acc), 2) != 0 do
|
||||
{bsr(value, 4), band(value, 0x0F)}
|
||||
else
|
||||
v = In.decode_byte(packet)
|
||||
{v, band(v, 0x0F)}
|
||||
end
|
||||
|
||||
decode_keypad_states(packet, remaining - 1, new_value, [decoded | acc])
|
||||
end
|
||||
|
||||
defp encode_element(%MoveElem{} = elem) do
|
||||
attr = elem.attribute
|
||||
|
||||
base = <<attr::8>>
|
||||
|
||||
data =
|
||||
case attr do
|
||||
a when a in [0, 6, 13, 15, 37, 38] ->
|
||||
<<elem.x::16-little, elem.y::16-little,
|
||||
elem.vx::16-little, elem.vy::16-little,
|
||||
elem.fh::16-little>> <>
|
||||
if attr == 13 do
|
||||
<<elem.fall_start::16-little>>
|
||||
else
|
||||
<<>>
|
||||
end <>
|
||||
<<elem.offset_x::16-little, elem.offset_y::16-little>>
|
||||
|
||||
a when a in [1, 2, 14, 17, 19, 32, 33, 34, 35] ->
|
||||
<<elem.vx::16-little, elem.vy::16-little>>
|
||||
|
||||
a when a in [3, 4, 5, 7, 8, 9, 11] ->
|
||||
<<elem.x::16-little, elem.y::16-little,
|
||||
elem.fh::16-little>>
|
||||
|
||||
10 ->
|
||||
<<elem.sn::8>>
|
||||
|
||||
12 ->
|
||||
<<elem.vx::16-little, elem.vy::16-little,
|
||||
elem.fall_start::16-little>>
|
||||
|
||||
18 ->
|
||||
<<elem.x::16-little, elem.y::16-little,
|
||||
elem.vx::16-little, elem.vy::16-little>>
|
||||
|
||||
a when a in [21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31] ->
|
||||
<<>>
|
||||
|
||||
36 ->
|
||||
<<elem.x::16-little, elem.y::16-little,
|
||||
elem.vx::16-little, elem.vy::16-little,
|
||||
elem.fh::16-little>>
|
||||
|
||||
_ ->
|
||||
<<>>
|
||||
end
|
||||
|
||||
footer =
|
||||
if attr != 10 do
|
||||
<<elem.move_action::8, elem.elapse::16-little>>
|
||||
else
|
||||
<<>>
|
||||
end
|
||||
|
||||
base <> data <> footer
|
||||
end
|
||||
end
|
||||
29
lib/odinsea/game/movement/relative.ex
Normal file
29
lib/odinsea/game/movement/relative.ex
Normal file
@@ -0,0 +1,29 @@
|
||||
defmodule Odinsea.Game.Movement.Relative do
|
||||
@moduledoc """
|
||||
Relative life movement - small position adjustments.
|
||||
Ported from Java RelativeLifeMovement.java
|
||||
|
||||
Used for:
|
||||
- Small adjustments (commands 1, 2)
|
||||
- Float movements (commands 33, 34, 36)
|
||||
- Fine-tuning position
|
||||
|
||||
Contains relative offset from current position.
|
||||
"""
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
command: integer(), # Movement command type (1, 2, 33, 34, 36)
|
||||
x: integer(), # X offset (delta from current)
|
||||
y: integer(), # Y offset (delta from current)
|
||||
stance: integer(), # New stance/move action
|
||||
duration: integer() # Movement duration in ms
|
||||
}
|
||||
|
||||
defstruct [
|
||||
:command,
|
||||
:x,
|
||||
:y,
|
||||
:stance,
|
||||
:duration
|
||||
]
|
||||
end
|
||||
32
lib/odinsea/game/movement/teleport.ex
Normal file
32
lib/odinsea/game/movement/teleport.ex
Normal file
@@ -0,0 +1,32 @@
|
||||
defmodule Odinsea.Game.Movement.Teleport do
|
||||
@moduledoc """
|
||||
Teleport movement - instant position change.
|
||||
Ported from Java TeleportMovement.java
|
||||
|
||||
Used for:
|
||||
- Rush skills (command 3)
|
||||
- Teleport (command 4)
|
||||
- Assassinate (command 8)
|
||||
- Special skills (commands 100, 101)
|
||||
|
||||
Note: Duration is always 0 for teleports.
|
||||
"""
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
command: integer(), # Movement command type (3, 4, 8, 100, 101)
|
||||
x: integer(), # Target X position
|
||||
y: integer(), # Target Y position
|
||||
vx: integer(), # X velocity (visual effect)
|
||||
vy: integer(), # Y velocity (visual effect)
|
||||
stance: integer() # New stance/move action
|
||||
}
|
||||
|
||||
defstruct [
|
||||
:command,
|
||||
:x,
|
||||
:y,
|
||||
:vx,
|
||||
:vy,
|
||||
:stance
|
||||
]
|
||||
end
|
||||
36
lib/odinsea/game/movement/unknown.ex
Normal file
36
lib/odinsea/game/movement/unknown.ex
Normal file
@@ -0,0 +1,36 @@
|
||||
defmodule Odinsea.Game.Movement.Unknown do
|
||||
@moduledoc """
|
||||
Unknown movement type - placeholder for unhandled commands.
|
||||
Ported from Java UnknownMovement.java
|
||||
|
||||
Used for:
|
||||
- Command 32 (unknown structure)
|
||||
- Any future/unrecognized movement types
|
||||
|
||||
Parses generic structure that may match unknown commands.
|
||||
"""
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
command: integer(), # Movement command type (32, or unknown)
|
||||
unk: integer(), # Unknown short value
|
||||
x: integer(), # X position
|
||||
y: integer(), # Y position
|
||||
vx: integer(), # X velocity
|
||||
vy: integer(), # Y velocity
|
||||
foothold: integer(), # Foothold
|
||||
stance: integer(), # New stance/move action
|
||||
duration: integer() # Movement duration in ms
|
||||
}
|
||||
|
||||
defstruct [
|
||||
:command,
|
||||
:unk,
|
||||
:x,
|
||||
:y,
|
||||
:vx,
|
||||
:vy,
|
||||
:foothold,
|
||||
:stance,
|
||||
:duration
|
||||
]
|
||||
end
|
||||
332
lib/odinsea/game/pet.ex
Normal file
332
lib/odinsea/game/pet.ex
Normal file
@@ -0,0 +1,332 @@
|
||||
defmodule Odinsea.Game.Pet do
|
||||
@moduledoc """
|
||||
Represents a pet in the game.
|
||||
Ported from src/client/inventory/MaplePet.java
|
||||
|
||||
Pets are companions that follow players, can pick up items, and provide buffs.
|
||||
Each pet has:
|
||||
- Level and closeness (affection) that grows through interaction
|
||||
- Fullness (hunger) that must be maintained by feeding
|
||||
- Flags for special abilities (item pickup, auto-buff, etc.)
|
||||
"""
|
||||
|
||||
alias Odinsea.Game.PetData
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
# Identity
|
||||
unique_id: integer(),
|
||||
pet_item_id: integer(),
|
||||
name: String.t(),
|
||||
|
||||
# Stats
|
||||
level: byte(),
|
||||
closeness: integer(),
|
||||
fullness: byte(),
|
||||
|
||||
# Position (when summoned)
|
||||
position: %{x: integer(), y: integer(), fh: integer()},
|
||||
stance: integer(),
|
||||
|
||||
# State
|
||||
summoned: byte(),
|
||||
inventory_position: integer(),
|
||||
seconds_left: integer(),
|
||||
|
||||
# Abilities (bitmask flags)
|
||||
flags: integer(),
|
||||
|
||||
# Change tracking
|
||||
changed: boolean()
|
||||
}
|
||||
|
||||
defstruct [
|
||||
:unique_id,
|
||||
:pet_item_id,
|
||||
:name,
|
||||
:level,
|
||||
:closeness,
|
||||
:fullness,
|
||||
:position,
|
||||
:stance,
|
||||
:summoned,
|
||||
:inventory_position,
|
||||
:seconds_left,
|
||||
:flags,
|
||||
:changed
|
||||
]
|
||||
|
||||
@max_closeness 30_000
|
||||
@max_fullness 100
|
||||
@default_fullness 100
|
||||
@default_level 1
|
||||
|
||||
@doc """
|
||||
Creates a new pet with default values.
|
||||
"""
|
||||
def new(pet_item_id, unique_id, name \\ nil) do
|
||||
name = name || PetData.get_default_pet_name(pet_item_id)
|
||||
|
||||
%__MODULE__{
|
||||
unique_id: unique_id,
|
||||
pet_item_id: pet_item_id,
|
||||
name: name,
|
||||
level: @default_level,
|
||||
closeness: 0,
|
||||
fullness: @default_fullness,
|
||||
position: %{x: 0, y: 0, fh: 0},
|
||||
stance: 0,
|
||||
summoned: 0,
|
||||
inventory_position: 0,
|
||||
seconds_left: 0,
|
||||
flags: 0,
|
||||
changed: true
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Creates a pet from database values.
|
||||
"""
|
||||
def from_db(pet_item_id, unique_id, attrs) do
|
||||
%__MODULE__{
|
||||
unique_id: unique_id,
|
||||
pet_item_id: pet_item_id,
|
||||
name: attrs[:name] || "",
|
||||
level: attrs[:level] || @default_level,
|
||||
closeness: attrs[:closeness] || 0,
|
||||
fullness: attrs[:fullness] || @default_fullness,
|
||||
position: %{x: 0, y: 0, fh: 0},
|
||||
stance: 0,
|
||||
summoned: 0,
|
||||
inventory_position: attrs[:inventory_position] || 0,
|
||||
seconds_left: attrs[:seconds_left] || 0,
|
||||
flags: attrs[:flags] || 0,
|
||||
changed: false
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Sets the pet's name.
|
||||
"""
|
||||
def set_name(%__MODULE__{} = pet, name) do
|
||||
%{pet | name: name, changed: true}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Sets the pet's summoned state.
|
||||
- 0 = not summoned
|
||||
- 1, 2, 3 = summoned in corresponding slot
|
||||
"""
|
||||
def set_summoned(%__MODULE__{} = pet, summoned) when summoned in [0, 1, 2, 3] do
|
||||
%{pet | summoned: summoned}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if the pet is currently summoned.
|
||||
"""
|
||||
def summoned?(%__MODULE__{} = pet) do
|
||||
pet.summoned > 0
|
||||
end
|
||||
|
||||
@doc """
|
||||
Sets the inventory position of the pet item.
|
||||
"""
|
||||
def set_inventory_position(%__MODULE__{} = pet, position) do
|
||||
%{pet | inventory_position: position}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Adds closeness (affection) to the pet.
|
||||
Returns {:level_up, pet} if pet leveled up, {:ok, pet} otherwise.
|
||||
"""
|
||||
def add_closeness(%__MODULE__{} = pet, amount) do
|
||||
new_closeness = min(@max_closeness, pet.closeness + amount)
|
||||
next_level_req = PetData.closeness_for_level(pet.level + 1)
|
||||
|
||||
pet = %{pet | closeness: new_closeness, changed: true}
|
||||
|
||||
if new_closeness >= next_level_req and pet.level < 30 do
|
||||
{:level_up, level_up(pet)}
|
||||
else
|
||||
{:ok, pet}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Removes closeness from the pet (e.g., when fullness is 0).
|
||||
May cause level down.
|
||||
Returns {:level_down, pet} if pet leveled down, {:ok, pet} otherwise.
|
||||
"""
|
||||
def remove_closeness(%__MODULE__{} = pet, amount) do
|
||||
new_closeness = max(0, pet.closeness - amount)
|
||||
current_level_req = PetData.closeness_for_level(pet.level)
|
||||
|
||||
pet = %{pet | closeness: new_closeness, changed: true}
|
||||
|
||||
if new_closeness < current_level_req and pet.level > 1 do
|
||||
{:level_down, %{pet | level: pet.level - 1}}
|
||||
else
|
||||
{:ok, pet}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Levels up the pet.
|
||||
"""
|
||||
def level_up(%__MODULE__{} = pet) do
|
||||
%{pet | level: min(30, pet.level + 1), changed: true}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Adds fullness to the pet (when fed).
|
||||
Max fullness is 100.
|
||||
"""
|
||||
def add_fullness(%__MODULE__{} = pet, amount) do
|
||||
new_fullness = min(@max_fullness, pet.fullness + amount)
|
||||
%{pet | fullness: new_fullness, changed: true}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Decreases fullness (called periodically by hunger timer).
|
||||
May decrease closeness if fullness reaches 0.
|
||||
"""
|
||||
def decrease_fullness(%__MODULE__{} = pet, amount) do
|
||||
new_fullness = max(0, pet.fullness - amount)
|
||||
pet = %{pet | fullness: new_fullness, changed: true}
|
||||
|
||||
if new_fullness == 0 do
|
||||
# Pet loses closeness when starving
|
||||
remove_closeness(pet, 1)
|
||||
else
|
||||
{:ok, pet}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Sets the pet's fullness directly.
|
||||
"""
|
||||
def set_fullness(%__MODULE__{} = pet, fullness) do
|
||||
%{pet | fullness: max(0, min(@max_fullness, fullness)), changed: true}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Sets the pet's flags (abilities bitmask).
|
||||
"""
|
||||
def set_flags(%__MODULE__{} = pet, flags) do
|
||||
%{pet | flags: flags, changed: true}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Adds a flag to the pet's abilities.
|
||||
"""
|
||||
def add_flag(%__MODULE__{} = pet, flag) do
|
||||
%{pet | flags: Bitwise.bor(pet.flags, flag), changed: true}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Removes a flag from the pet's abilities.
|
||||
"""
|
||||
def remove_flag(%__MODULE__{} = pet, flag) do
|
||||
%{pet | flags: Bitwise.band(pet.flags, Bitwise.bnot(flag)), changed: true}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if the pet has a specific flag.
|
||||
"""
|
||||
def has_flag?(%__MODULE__{} = pet, flag) do
|
||||
Bitwise.band(pet.flags, flag) == flag
|
||||
end
|
||||
|
||||
@doc """
|
||||
Updates the pet's position.
|
||||
"""
|
||||
def update_position(%__MODULE__{} = pet, x, y, fh \\ nil, stance \\ nil) do
|
||||
new_position = %{pet.position | x: x, y: y}
|
||||
new_position = if fh, do: %{new_position | fh: fh}, else: new_position
|
||||
|
||||
pet = %{pet | position: new_position}
|
||||
pet = if stance, do: %{pet | stance: stance}, else: pet
|
||||
|
||||
pet
|
||||
end
|
||||
|
||||
@doc """
|
||||
Sets the seconds left (for time-limited pets).
|
||||
"""
|
||||
def set_seconds_left(%__MODULE__{} = pet, seconds) do
|
||||
%{pet | seconds_left: seconds, changed: true}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Decreases seconds left for time-limited pets.
|
||||
Returns {:expired, pet} if time runs out, {:ok, pet} otherwise.
|
||||
"""
|
||||
def tick_seconds(%__MODULE__{} = pet) do
|
||||
if pet.seconds_left > 0 do
|
||||
new_seconds = pet.seconds_left - 1
|
||||
pet = %{pet | seconds_left: new_seconds, changed: true}
|
||||
|
||||
if new_seconds == 0 do
|
||||
{:expired, pet}
|
||||
else
|
||||
{:ok, pet}
|
||||
end
|
||||
else
|
||||
{:ok, pet}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Marks the pet as saved (clears changed flag).
|
||||
"""
|
||||
def mark_saved(%__MODULE__{} = pet) do
|
||||
%{pet | changed: false}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if the pet can consume a specific food item.
|
||||
"""
|
||||
def can_consume?(%__MODULE__{} = pet, item_id) do
|
||||
# Different pets can eat different foods
|
||||
# This would check against item data for valid pet foods
|
||||
item_id >= 5_120_000 and item_id < 5_130_000
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the pet's hunger rate (how fast fullness decreases).
|
||||
Based on pet item ID.
|
||||
"""
|
||||
def get_hunger(%__MODULE__{} = pet) do
|
||||
PetData.get_hunger(pet.pet_item_id)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the pet's progress to next level as a percentage.
|
||||
"""
|
||||
def level_progress(%__MODULE__{} = pet) do
|
||||
current_req = PetData.closeness_for_level(pet.level)
|
||||
next_req = PetData.closeness_for_level(pet.level + 1)
|
||||
|
||||
if next_req == current_req do
|
||||
100
|
||||
else
|
||||
progress = pet.closeness - current_req
|
||||
needed = next_req - current_req
|
||||
trunc(progress / needed * 100)
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Converts pet to a map for database storage.
|
||||
"""
|
||||
def to_db_map(%__MODULE__{} = pet) do
|
||||
%{
|
||||
petid: pet.unique_id,
|
||||
name: pet.name,
|
||||
level: pet.level,
|
||||
closeness: pet.closeness,
|
||||
fullness: pet.fullness,
|
||||
seconds: pet.seconds_left,
|
||||
flags: pet.flags
|
||||
}
|
||||
end
|
||||
end
|
||||
535
lib/odinsea/game/pet_data.ex
Normal file
535
lib/odinsea/game/pet_data.ex
Normal file
@@ -0,0 +1,535 @@
|
||||
defmodule Odinsea.Game.PetData do
|
||||
@moduledoc """
|
||||
Pet data definitions and lookup functions.
|
||||
Ported from src/client/inventory/PetDataFactory.java
|
||||
and src/server/MapleItemInformationProvider.java (pet methods)
|
||||
and src/constants/GameConstants.java (closeness array)
|
||||
|
||||
Provides:
|
||||
- Pet command data (probability and closeness increase for each command)
|
||||
- Hunger rates per pet
|
||||
- Closeness needed for each level
|
||||
- Pet flag definitions (abilities)
|
||||
"""
|
||||
|
||||
require Logger
|
||||
|
||||
# ============================================================================
|
||||
# Pet Commands
|
||||
# ============================================================================
|
||||
|
||||
# Command data structure: {probability, closeness_increase}
|
||||
# Probability is 0-100 representing % chance of success
|
||||
# Default commands for pets without specific data
|
||||
@default_commands %{
|
||||
0 => {90, 1}, # Default command 0
|
||||
1 => {90, 1}, # Default command 1
|
||||
2 => {80, 2}, # Default command 2
|
||||
3 => {70, 2}, # Default command 3
|
||||
4 => {60, 3}, # Default command 4
|
||||
5 => {50, 3} # Default command 5
|
||||
}
|
||||
|
||||
# Pet-specific command overrides
|
||||
# Format: pet_item_id => %{command_id => {probability, closeness_increase}}
|
||||
@pet_commands %{
|
||||
# Brown Kitty (5000000)
|
||||
5_000_000 => %{
|
||||
0 => {95, 1},
|
||||
1 => {90, 1},
|
||||
2 => {85, 2},
|
||||
3 => {80, 2},
|
||||
4 => {75, 3}
|
||||
},
|
||||
# Black Kitty (5000001)
|
||||
5_000_001 => %{
|
||||
0 => {95, 1},
|
||||
1 => {90, 1},
|
||||
2 => {85, 2},
|
||||
3 => {80, 2},
|
||||
4 => {75, 3}
|
||||
},
|
||||
# Panda (5000002)
|
||||
5_000_002 => %{
|
||||
0 => {90, 1},
|
||||
1 => {85, 1},
|
||||
2 => {80, 2},
|
||||
3 => {75, 2},
|
||||
4 => {70, 3}
|
||||
},
|
||||
# Brown Puppy (5000003)
|
||||
5_000_003 => %{
|
||||
0 => {95, 1},
|
||||
1 => {90, 1},
|
||||
2 => {85, 2},
|
||||
3 => {80, 2},
|
||||
4 => {75, 3}
|
||||
},
|
||||
# Beagle (5000004)
|
||||
5_000_004 => %{
|
||||
0 => {95, 1},
|
||||
1 => {90, 1},
|
||||
2 => {85, 2},
|
||||
3 => {80, 2},
|
||||
4 => {75, 3}
|
||||
},
|
||||
# Pink Bunny (5000005)
|
||||
5_000_005 => %{
|
||||
0 => {90, 1},
|
||||
1 => {85, 1},
|
||||
2 => {80, 2},
|
||||
3 => {75, 2},
|
||||
4 => {70, 3}
|
||||
},
|
||||
# Husky (5000006)
|
||||
5_000_006 => %{
|
||||
0 => {95, 1},
|
||||
1 => {90, 1},
|
||||
2 => {85, 2},
|
||||
3 => {80, 2},
|
||||
4 => {75, 3}
|
||||
},
|
||||
# Dalmation (5000007)
|
||||
5_000_007 => %{
|
||||
0 => {90, 1},
|
||||
1 => {85, 1},
|
||||
2 => {80, 2},
|
||||
3 => {75, 2},
|
||||
4 => {70, 3}
|
||||
},
|
||||
# Baby Dragon (5000008 - 5000013)
|
||||
5_000_008 => %{
|
||||
0 => {90, 1},
|
||||
1 => {85, 1},
|
||||
2 => {80, 2},
|
||||
3 => {75, 2},
|
||||
4 => {70, 3},
|
||||
5 => {60, 4}
|
||||
},
|
||||
5_000_009 => %{
|
||||
0 => {90, 1},
|
||||
1 => {85, 1},
|
||||
2 => {80, 2},
|
||||
3 => {75, 2},
|
||||
4 => {70, 3},
|
||||
5 => {60, 4}
|
||||
},
|
||||
5_000_010 => %{
|
||||
0 => {90, 1},
|
||||
1 => {85, 1},
|
||||
2 => {80, 2},
|
||||
3 => {75, 2},
|
||||
4 => {70, 3},
|
||||
5 => {60, 4}
|
||||
},
|
||||
5_000_011 => %{
|
||||
0 => {90, 1},
|
||||
1 => {85, 1},
|
||||
2 => {80, 2},
|
||||
3 => {75, 2},
|
||||
4 => {70, 3},
|
||||
5 => {60, 4}
|
||||
},
|
||||
5_000_012 => %{
|
||||
0 => {90, 1},
|
||||
1 => {85, 1},
|
||||
2 => {80, 2},
|
||||
3 => {75, 2},
|
||||
4 => {70, 3},
|
||||
5 => {60, 4}
|
||||
},
|
||||
5_000_013 => %{
|
||||
0 => {90, 1},
|
||||
1 => {85, 1},
|
||||
2 => {80, 2},
|
||||
3 => {75, 2},
|
||||
4 => {70, 3},
|
||||
5 => {60, 4}
|
||||
},
|
||||
# Jr. Balrog (5000014)
|
||||
5_000_014 => %{
|
||||
0 => {85, 1},
|
||||
1 => {80, 1},
|
||||
2 => {75, 2},
|
||||
3 => {70, 2},
|
||||
4 => {65, 3},
|
||||
5 => {55, 4}
|
||||
},
|
||||
# White Tiger (5000015)
|
||||
5_000_015 => %{
|
||||
0 => {90, 1},
|
||||
1 => {85, 1},
|
||||
2 => {80, 2},
|
||||
3 => {75, 2},
|
||||
4 => {70, 3}
|
||||
},
|
||||
# Penguin (5000016)
|
||||
5_000_016 => %{
|
||||
0 => {90, 1},
|
||||
1 => {85, 1},
|
||||
2 => {80, 2},
|
||||
3 => {75, 2},
|
||||
4 => {70, 3}
|
||||
},
|
||||
# Jr. Yeti (5000017)
|
||||
5_000_017 => %{
|
||||
0 => {90, 1},
|
||||
1 => {85, 1},
|
||||
2 => {80, 2},
|
||||
3 => {75, 2},
|
||||
4 => {70, 3}
|
||||
},
|
||||
# Golden Pig (5000018)
|
||||
5_000_018 => %{
|
||||
0 => {85, 1},
|
||||
1 => {80, 1},
|
||||
2 => {75, 2},
|
||||
3 => {70, 2},
|
||||
4 => {65, 3}
|
||||
},
|
||||
# Robot (5000019)
|
||||
5_000_019 => %{
|
||||
0 => {90, 1},
|
||||
1 => {85, 1},
|
||||
2 => {80, 2},
|
||||
3 => {75, 2},
|
||||
4 => {70, 3}
|
||||
},
|
||||
# Elf (5000020)
|
||||
5_000_020 => %{
|
||||
0 => {90, 1},
|
||||
1 => {85, 1},
|
||||
2 => {80, 2},
|
||||
3 => {75, 2},
|
||||
4 => {70, 3}
|
||||
},
|
||||
# Pandas (5000021, 5000022)
|
||||
5_000_021 => %{
|
||||
0 => {90, 1},
|
||||
1 => {85, 1},
|
||||
2 => {80, 2},
|
||||
3 => {75, 2},
|
||||
4 => {70, 3}
|
||||
},
|
||||
5_000_022 => %{
|
||||
0 => {90, 1},
|
||||
1 => {85, 1},
|
||||
2 => {80, 2},
|
||||
3 => {75, 2},
|
||||
4 => {70, 3}
|
||||
},
|
||||
# Ghost (5000023)
|
||||
5_000_023 => %{
|
||||
0 => {85, 1},
|
||||
1 => {80, 1},
|
||||
2 => {75, 2},
|
||||
3 => {70, 2},
|
||||
4 => {65, 3}
|
||||
},
|
||||
# Jr. Reaper (5000024)
|
||||
5_000_024 => %{
|
||||
0 => {85, 1},
|
||||
1 => {80, 1},
|
||||
2 => {75, 2},
|
||||
3 => {70, 2},
|
||||
4 => {65, 3}
|
||||
},
|
||||
# Mini Yeti (5000025)
|
||||
5_000_025 => %{
|
||||
0 => {90, 1},
|
||||
1 => {85, 1},
|
||||
2 => {80, 2},
|
||||
3 => {75, 2},
|
||||
4 => {70, 3}
|
||||
},
|
||||
# Kino (5000026)
|
||||
5_000_026 => %{
|
||||
0 => {90, 1},
|
||||
1 => {85, 1},
|
||||
2 => {80, 2},
|
||||
3 => {75, 2},
|
||||
4 => {70, 3}
|
||||
},
|
||||
# White Tiger (5000027)
|
||||
5_000_027 => %{
|
||||
0 => {90, 1},
|
||||
1 => {85, 1},
|
||||
2 => {80, 2},
|
||||
3 => {75, 2},
|
||||
4 => {70, 3}
|
||||
}
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Closeness Needed Per Level
|
||||
# ============================================================================
|
||||
|
||||
# Cumulative closeness needed for each level (index 0 = level 1)
|
||||
@closeness_levels [
|
||||
0, # Level 1
|
||||
1, # Level 2
|
||||
3, # Level 3
|
||||
6, # Level 4
|
||||
14, # Level 5
|
||||
31, # Level 6
|
||||
60, # Level 7
|
||||
108, # Level 8
|
||||
181, # Level 9
|
||||
287, # Level 10
|
||||
434, # Level 11
|
||||
632, # Level 12
|
||||
891, # Level 13
|
||||
1224, # Level 14
|
||||
1642, # Level 15
|
||||
2161, # Level 16
|
||||
2793, # Level 17
|
||||
3557, # Level 18
|
||||
4467, # Level 19
|
||||
5542, # Level 20
|
||||
6801, # Level 21
|
||||
8263, # Level 22
|
||||
9950, # Level 23
|
||||
11882, # Level 24
|
||||
14084, # Level 25
|
||||
16578, # Level 26
|
||||
19391, # Level 27
|
||||
22547, # Level 28
|
||||
26074, # Level 29
|
||||
30000 # Level 30 (Max)
|
||||
]
|
||||
|
||||
# ============================================================================
|
||||
# Hunger Rates
|
||||
# ============================================================================
|
||||
|
||||
# Default hunger rate (fullness lost per tick)
|
||||
@default_hunger 10
|
||||
|
||||
# Pet-specific hunger rates
|
||||
@hunger_rates %{
|
||||
# Event/special pets have higher hunger rates
|
||||
5_000_054 => 5, # Time-limited pets
|
||||
5_000_067 => 5, # Permanent pet (slower hunger)
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Pet Flags (Abilities)
|
||||
# ============================================================================
|
||||
|
||||
defmodule PetFlag do
|
||||
@moduledoc """
|
||||
Pet ability flags (ported from MaplePet.PetFlag enum).
|
||||
These are bitflags that can be combined.
|
||||
"""
|
||||
|
||||
# Flag values
|
||||
@item_pickup 0x01
|
||||
@expand_pickup 0x02
|
||||
@auto_pickup 0x04
|
||||
@unpickable 0x08
|
||||
@leftover_pickup 0x10
|
||||
@hp_charge 0x20
|
||||
@mp_charge 0x40
|
||||
@pet_buff 0x80
|
||||
@pet_draw 0x100
|
||||
@pet_dialogue 0x200
|
||||
|
||||
def item_pickup, do: @item_pickup
|
||||
def expand_pickup, do: @expand_pickup
|
||||
def auto_pickup, do: @auto_pickup
|
||||
def unpickable, do: @unpickable
|
||||
def leftover_pickup, do: @leftover_pickup
|
||||
def hp_charge, do: @hp_charge
|
||||
def mp_charge, do: @mp_charge
|
||||
def pet_buff, do: @pet_buff
|
||||
def pet_draw, do: @pet_draw
|
||||
def pet_dialogue, do: @pet_dialogue
|
||||
|
||||
# Item IDs that add each flag
|
||||
@item_to_flag %{
|
||||
5_190_000 => @item_pickup,
|
||||
5_190_001 => @hp_charge,
|
||||
5_190_002 => @expand_pickup,
|
||||
5_190_003 => @auto_pickup,
|
||||
5_190_004 => @leftover_pickup,
|
||||
5_190_005 => @unpickable,
|
||||
5_190_006 => @mp_charge,
|
||||
5_190_007 => @pet_draw,
|
||||
5_190_008 => @pet_dialogue,
|
||||
# 1000-series items also add flags
|
||||
5_191_000 => @item_pickup,
|
||||
5_191_001 => @hp_charge,
|
||||
5_191_002 => @expand_pickup,
|
||||
5_191_003 => @auto_pickup,
|
||||
5_191_004 => @leftover_pickup
|
||||
}
|
||||
|
||||
@doc """
|
||||
Gets the flag value for an item ID.
|
||||
"""
|
||||
def get_by_item_id(item_id) do
|
||||
Map.get(@item_to_flag, item_id)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a human-readable name for a flag.
|
||||
"""
|
||||
def name(flag) do
|
||||
case flag do
|
||||
@item_pickup -> "pickupItem"
|
||||
@expand_pickup -> "longRange"
|
||||
@auto_pickup -> "dropSweep"
|
||||
@unpickable -> "ignorePickup"
|
||||
@leftover_pickup -> "pickupAll"
|
||||
@hp_charge -> "consumeHP"
|
||||
@mp_charge -> "consumeMP"
|
||||
@pet_buff -> "autoBuff"
|
||||
@pet_draw -> "recall"
|
||||
@pet_dialogue -> "autoSpeaking"
|
||||
_ -> "unknown"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Public API
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Gets the closeness needed for a specific level.
|
||||
Returns the cumulative closeness required to reach that level.
|
||||
"""
|
||||
def closeness_for_level(level) when level >= 1 and level <= 30 do
|
||||
Enum.at(@closeness_levels, level - 1, 30_000)
|
||||
end
|
||||
|
||||
def closeness_for_level(level) when level > 30, do: 30_000
|
||||
def closeness_for_level(_level), do: 0
|
||||
|
||||
@doc """
|
||||
Gets pet command data (probability and closeness increase).
|
||||
Returns {probability, closeness_increase} or nil if command doesn't exist.
|
||||
"""
|
||||
def get_pet_command(pet_item_id, command_id) do
|
||||
commands = Map.get(@pet_commands, pet_item_id, @default_commands)
|
||||
Map.get(commands, command_id)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a random pet command for the pet.
|
||||
Used when player uses the "Random Pet Command" feature.
|
||||
Returns {command_id, {probability, closeness_increase}} or nil.
|
||||
"""
|
||||
def get_random_pet_command(pet_item_id) do
|
||||
commands = Map.get(@pet_commands, pet_item_id, @default_commands)
|
||||
|
||||
if Enum.empty?(commands) do
|
||||
nil
|
||||
else
|
||||
{command_id, data} = Enum.random(commands)
|
||||
{command_id, data}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the hunger rate for a pet (how fast fullness decreases).
|
||||
Lower values mean slower hunger.
|
||||
"""
|
||||
def get_hunger(pet_item_id) do
|
||||
Map.get(@hunger_rates, pet_item_id, @default_hunger)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the default name for a pet based on its item ID.
|
||||
"""
|
||||
def get_default_pet_name(pet_item_id) do
|
||||
# Map of pet item IDs to their default names
|
||||
names = %{
|
||||
5_000_000 => "Brown Kitty",
|
||||
5_000_001 => "Black Kitty",
|
||||
5_000_002 => "Panda",
|
||||
5_000_003 => "Brown Puppy",
|
||||
5_000_004 => "Beagle",
|
||||
5_000_005 => "Pink Bunny",
|
||||
5_000_006 => "Husky",
|
||||
5_000_007 => "Dalmation",
|
||||
5_000_008 => "Baby Dragon (Red)",
|
||||
5_000_009 => "Baby Dragon (Blue)",
|
||||
5_000_010 => "Baby Dragon (Green)",
|
||||
5_000_011 => "Baby Dragon (Black)",
|
||||
5_000_012 => "Baby Dragon (Gold)",
|
||||
5_000_013 => "Baby Dragon (Purple)",
|
||||
5_000_014 => "Jr. Balrog",
|
||||
5_000_015 => "White Tiger",
|
||||
5_000_016 => "Penguin",
|
||||
5_000_017 => "Jr. Yeti",
|
||||
5_000_018 => "Golden Pig",
|
||||
5_000_019 => "Robo",
|
||||
5_000_020 => "Fairy",
|
||||
5_000_021 => "Panda (White)",
|
||||
5_000_022 => "Panda (Pink)",
|
||||
5_000_023 => "Ghost",
|
||||
5_000_024 => "Jr. Reaper",
|
||||
5_000_025 => "Mini Yeti",
|
||||
5_000_026 => "Kino",
|
||||
5_000_027 => "White Tiger (Striped)"
|
||||
}
|
||||
|
||||
Map.get(names, pet_item_id, "Pet")
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if an item ID is a pet egg (can be hatched into a pet).
|
||||
"""
|
||||
def pet_egg?(item_id) do
|
||||
# Pet eggs are in range 5000000-5000100
|
||||
item_id >= 5_000_000 and item_id < 5_000_100
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if an item ID is pet food.
|
||||
"""
|
||||
def pet_food?(item_id) do
|
||||
# Pet food items are in range 2120000-2130000
|
||||
item_id >= 2_120_000 and item_id < 2_130_000
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the food value (fullness restored) for a pet food item.
|
||||
"""
|
||||
def get_food_value(item_id) do
|
||||
# Standard pet food restores 30 fullness
|
||||
if pet_food?(item_id) do
|
||||
30
|
||||
else
|
||||
0
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets pet equip slot mappings.
|
||||
Pets can equip special items that give them abilities.
|
||||
"""
|
||||
def pet_equip_slots do
|
||||
%{
|
||||
0 => :hat,
|
||||
1 => :saddle,
|
||||
2 => :decor
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if an item can be equipped by a pet.
|
||||
"""
|
||||
def pet_equip?(item_id) do
|
||||
# Pet equipment is in range 1802000-1803000
|
||||
item_id >= 1_802_000 and item_id < 1_803_000
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets all available pet commands for a pet.
|
||||
"""
|
||||
def list_pet_commands(pet_item_id) do
|
||||
Map.get(@pet_commands, pet_item_id, @default_commands)
|
||||
end
|
||||
end
|
||||
530
lib/odinsea/game/player_shop.ex
Normal file
530
lib/odinsea/game/player_shop.ex
Normal file
@@ -0,0 +1,530 @@
|
||||
defmodule Odinsea.Game.PlayerShop do
|
||||
@moduledoc """
|
||||
Player-owned shop (mushroom shop) system.
|
||||
Ported from src/server/shops/MaplePlayerShop.java
|
||||
|
||||
Player shops allow players to:
|
||||
- Open a shop with a shop permit item
|
||||
- List items for sale with prices
|
||||
- Allow other players to browse and buy
|
||||
- Support up to 3 visitors at once
|
||||
- Can ban unwanted visitors
|
||||
|
||||
Shop lifecycle:
|
||||
1. Owner creates shop with description
|
||||
2. Owner adds items to sell
|
||||
3. Owner opens shop (becomes visible on map)
|
||||
4. Visitors can enter and buy items
|
||||
5. Owner can close shop (returns unsold items)
|
||||
"""
|
||||
|
||||
use GenServer
|
||||
require Logger
|
||||
|
||||
alias Odinsea.Game.{ShopItem, Item, Equip}
|
||||
|
||||
# Shop type constant
|
||||
@shop_type 2
|
||||
|
||||
# Maximum visitors (excluding owner)
|
||||
@max_visitors 3
|
||||
|
||||
# Struct for the shop state
|
||||
defstruct [
|
||||
:id,
|
||||
:owner_id,
|
||||
:owner_account_id,
|
||||
:owner_name,
|
||||
:item_id,
|
||||
:description,
|
||||
:password,
|
||||
:map_id,
|
||||
:channel,
|
||||
:position,
|
||||
:meso,
|
||||
:items,
|
||||
:visitors,
|
||||
:visitor_names,
|
||||
:banned_list,
|
||||
:open,
|
||||
:available,
|
||||
:bought_items,
|
||||
:bought_count
|
||||
]
|
||||
|
||||
@doc """
|
||||
Starts a new player shop GenServer.
|
||||
"""
|
||||
def start_link(opts) do
|
||||
shop_id = Keyword.fetch!(opts, :id)
|
||||
GenServer.start_link(__MODULE__, opts, name: via_tuple(shop_id))
|
||||
end
|
||||
|
||||
@doc """
|
||||
Creates a new player shop.
|
||||
"""
|
||||
def create(opts) do
|
||||
%__MODULE__{
|
||||
id: opts[:id] || generate_id(),
|
||||
owner_id: opts[:owner_id],
|
||||
owner_account_id: opts[:owner_account_id],
|
||||
owner_name: opts[:owner_name],
|
||||
item_id: opts[:item_id],
|
||||
description: opts[:description] || "",
|
||||
password: opts[:password] || "",
|
||||
map_id: opts[:map_id],
|
||||
channel: opts[:channel],
|
||||
position: opts[:position],
|
||||
meso: 0,
|
||||
items: [],
|
||||
visitors: %{},
|
||||
visitor_names: [],
|
||||
banned_list: [],
|
||||
open: false,
|
||||
available: false,
|
||||
bought_items: [],
|
||||
bought_count: 0
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the shop type (2 = player shop).
|
||||
"""
|
||||
def shop_type, do: @shop_type
|
||||
|
||||
@doc """
|
||||
Gets the current shop state.
|
||||
"""
|
||||
def get_state(shop_pid) when is_pid(shop_pid) do
|
||||
GenServer.call(shop_pid, :get_state)
|
||||
end
|
||||
|
||||
def get_state(shop_id) do
|
||||
case lookup(shop_id) do
|
||||
{:ok, pid} -> get_state(pid)
|
||||
error -> error
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Looks up a shop by ID.
|
||||
"""
|
||||
def lookup(shop_id) do
|
||||
case Registry.lookup(Odinsea.ShopRegistry, shop_id) do
|
||||
[{pid, _}] -> {:ok, pid}
|
||||
[] -> {:error, :not_found}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Adds an item to the shop.
|
||||
"""
|
||||
def add_item(shop_id, %ShopItem{} = item) do
|
||||
with {:ok, pid} <- lookup(shop_id) do
|
||||
GenServer.call(pid, {:add_item, item})
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Removes an item from the shop by slot.
|
||||
"""
|
||||
def remove_item(shop_id, slot) do
|
||||
with {:ok, pid} <- lookup(shop_id) do
|
||||
GenServer.call(pid, {:remove_item, slot})
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Buys an item from the shop.
|
||||
Returns {:ok, item, price} on success or {:error, reason} on failure.
|
||||
"""
|
||||
def buy_item(shop_id, slot, quantity, buyer_id, buyer_name) do
|
||||
with {:ok, pid} <- lookup(shop_id) do
|
||||
GenServer.call(pid, {:buy_item, slot, quantity, buyer_id, buyer_name})
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Adds a visitor to the shop.
|
||||
Returns the visitor slot (1-3) or {:error, :full}.
|
||||
"""
|
||||
def add_visitor(shop_id, character_id, character_pid) do
|
||||
with {:ok, pid} <- lookup(shop_id) do
|
||||
GenServer.call(pid, {:add_visitor, character_id, character_pid})
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Removes a visitor from the shop.
|
||||
"""
|
||||
def remove_visitor(shop_id, character_id) do
|
||||
with {:ok, pid} <- lookup(shop_id) do
|
||||
GenServer.call(pid, {:remove_visitor, character_id})
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Bans a player from the shop.
|
||||
"""
|
||||
def ban_player(shop_id, character_name) do
|
||||
with {:ok, pid} <- lookup(shop_id) do
|
||||
GenServer.call(pid, {:ban_player, character_name})
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if a player is banned from the shop.
|
||||
"""
|
||||
def is_banned?(shop_id, character_name) do
|
||||
with {:ok, pid} <- lookup(shop_id) do
|
||||
GenServer.call(pid, {:is_banned, character_name})
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Sets the shop open status.
|
||||
"""
|
||||
def set_open(shop_id, open) do
|
||||
with {:ok, pid} <- lookup(shop_id) do
|
||||
GenServer.call(pid, {:set_open, open})
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Sets the shop available status (visible on map).
|
||||
"""
|
||||
def set_available(shop_id, available) do
|
||||
with {:ok, pid} <- lookup(shop_id) do
|
||||
GenServer.call(pid, {:set_available, available})
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a free visitor slot.
|
||||
Returns slot number (1-3) or nil if full.
|
||||
"""
|
||||
def get_free_slot(shop_id) do
|
||||
with {:ok, pid} <- lookup(shop_id) do
|
||||
GenServer.call(pid, :get_free_slot)
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the visitor slot for a character.
|
||||
Returns slot number (0 for owner, 1-3 for visitors, -1 if not found).
|
||||
"""
|
||||
def get_visitor_slot(shop_id, character_id) do
|
||||
with {:ok, pid} <- lookup(shop_id) do
|
||||
GenServer.call(pid, {:get_visitor_slot, character_id})
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if the character is the owner.
|
||||
"""
|
||||
def is_owner?(shop_id, character_id, character_name) do
|
||||
with {:ok, pid} <- lookup(shop_id) do
|
||||
GenServer.call(pid, {:is_owner, character_id, character_name})
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Closes the shop and returns unsold items.
|
||||
"""
|
||||
def close_shop(shop_id, save_items \\ false) do
|
||||
with {:ok, pid} <- lookup(shop_id) do
|
||||
GenServer.call(pid, {:close_shop, save_items})
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the current meso amount in the shop.
|
||||
"""
|
||||
def get_meso(shop_id) do
|
||||
with {:ok, pid} <- lookup(shop_id) do
|
||||
GenServer.call(pid, :get_meso)
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Sets the meso amount in the shop.
|
||||
"""
|
||||
def set_meso(shop_id, meso) do
|
||||
with {:ok, pid} <- lookup(shop_id) do
|
||||
GenServer.call(pid, {:set_meso, meso})
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Broadcasts a packet to all visitors.
|
||||
"""
|
||||
def broadcast_to_visitors(shop_id, packet, include_owner \\ true) do
|
||||
with {:ok, pid} <- lookup(shop_id) do
|
||||
GenServer.cast(pid, {:broadcast, packet, include_owner})
|
||||
end
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# GenServer Callbacks
|
||||
# ============================================================================
|
||||
|
||||
@impl true
|
||||
def init(opts) do
|
||||
state = create(opts)
|
||||
{:ok, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call(:get_state, _from, state) do
|
||||
{:reply, state, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:add_item, item}, _from, state) do
|
||||
new_items = state.items ++ [item]
|
||||
{:reply, :ok, %{state | items: new_items}}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:remove_item, slot}, _from, state) do
|
||||
if slot >= 0 and slot < length(state.items) do
|
||||
{removed, new_items} = List.pop_at(state.items, slot)
|
||||
{:reply, {:ok, removed}, %{state | items: new_items}}
|
||||
else
|
||||
{:reply, {:error, :invalid_slot}, state}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:buy_item, slot, quantity, buyer_id, buyer_name}, _from, state) do
|
||||
cond do
|
||||
slot < 0 or slot >= length(state.items) ->
|
||||
{:reply, {:error, :invalid_slot}, state}
|
||||
|
||||
true ->
|
||||
shop_item = Enum.at(state.items, slot)
|
||||
|
||||
cond do
|
||||
shop_item.bundles < quantity ->
|
||||
{:reply, {:error, :not_enough_stock}, state}
|
||||
|
||||
true ->
|
||||
# Create bought item record
|
||||
price = shop_item.price * quantity
|
||||
|
||||
bought_record = %{
|
||||
item_id: shop_item.item.item_id,
|
||||
quantity: quantity,
|
||||
total_price: price,
|
||||
buyer: buyer_name
|
||||
}
|
||||
|
||||
# Reduce bundles
|
||||
updated_item = ShopItem.reduce_bundles(shop_item, quantity)
|
||||
|
||||
# Update items list
|
||||
new_items =
|
||||
if ShopItem.sold_out?(updated_item) do
|
||||
List.delete_at(state.items, slot)
|
||||
else
|
||||
List.replace_at(state.items, slot, updated_item)
|
||||
end
|
||||
|
||||
# Create item for buyer
|
||||
buyer_item = ShopItem.create_buyer_item(shop_item, quantity)
|
||||
|
||||
# Update state
|
||||
new_bought_items = [bought_record | state.bought_items]
|
||||
new_bought_count = state.bought_count + 1
|
||||
|
||||
# Check if all items sold
|
||||
should_close = new_bought_count >= length(state.items) and new_items == []
|
||||
|
||||
new_state = %{
|
||||
state
|
||||
| items: new_items,
|
||||
bought_items: new_bought_items,
|
||||
bought_count: new_bought_count
|
||||
}
|
||||
|
||||
if should_close do
|
||||
{:reply, {:ok, buyer_item, price, :close}, new_state}
|
||||
else
|
||||
{:reply, {:ok, buyer_item, price, :continue}, new_state}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:add_visitor, character_id, character_pid}, _from, state) do
|
||||
# Check if already a visitor
|
||||
if Map.has_key?(state.visitors, character_id) do
|
||||
slot = get_slot_for_character(state, character_id)
|
||||
{:reply, {:ok, slot}, state}
|
||||
else
|
||||
# Find free slot
|
||||
case find_free_slot(state) do
|
||||
nil ->
|
||||
{:reply, {:error, :full}, state}
|
||||
|
||||
slot ->
|
||||
new_visitors = Map.put(state.visitors, character_id, %{pid: character_pid, slot: slot})
|
||||
|
||||
# Track visitor name for history
|
||||
new_visitor_names =
|
||||
if character_id != state.owner_id do
|
||||
[character_id | state.visitor_names]
|
||||
else
|
||||
state.visitor_names
|
||||
end
|
||||
|
||||
new_state = %{state | visitors: new_visitors, visitor_names: new_visitor_names}
|
||||
{:reply, {:ok, slot}, new_state}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:remove_visitor, character_id}, _from, state) do
|
||||
new_visitors = Map.delete(state.visitors, character_id)
|
||||
{:reply, :ok, %{state | visitors: new_visitors}}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:ban_player, character_name}, _from, state) do
|
||||
# Add to banned list
|
||||
new_banned =
|
||||
if character_name in state.banned_list do
|
||||
state.banned_list
|
||||
else
|
||||
[character_name | state.banned_list]
|
||||
end
|
||||
|
||||
# Find and remove if currently visiting
|
||||
visitor_to_remove =
|
||||
Enum.find(state.visitors, fn {_id, data} ->
|
||||
# This would need the character name, which we don't have in the state
|
||||
# For now, just ban from future visits
|
||||
false
|
||||
end)
|
||||
|
||||
new_visitors =
|
||||
case visitor_to_remove do
|
||||
{id, _} -> Map.delete(state.visitors, id)
|
||||
nil -> state.visitors
|
||||
end
|
||||
|
||||
{:reply, :ok, %{state | banned_list: new_banned, visitors: new_visitors}}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:is_banned, character_name}, _from, state) do
|
||||
{:reply, character_name in state.banned_list, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:set_open, open}, _from, state) do
|
||||
{:reply, :ok, %{state | open: open}}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:set_available, available}, _from, state) do
|
||||
{:reply, :ok, %{state | available: available}}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call(:get_free_slot, _from, state) do
|
||||
{:reply, find_free_slot(state), state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:get_visitor_slot, character_id}, _from, state) do
|
||||
slot = get_slot_for_character(state, character_id)
|
||||
{:reply, slot, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:is_owner, character_id, character_name}, _from, state) do
|
||||
is_owner = character_id == state.owner_id and character_name == state.owner_name
|
||||
{:reply, is_owner, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:close_shop, _save_items}, _from, state) do
|
||||
# Remove all visitors
|
||||
Enum.each(state.visitors, fn {_id, data} ->
|
||||
send(data.pid, {:shop_closed, state.id})
|
||||
end)
|
||||
|
||||
# Return unsold items to owner
|
||||
unsold_items =
|
||||
Enum.filter(state.items, fn item -> item.bundles > 0 end)
|
||||
|> Enum.map(fn shop_item ->
|
||||
item = shop_item.item
|
||||
total_qty = shop_item.bundles * item.quantity
|
||||
%{item | quantity: total_qty}
|
||||
end)
|
||||
|
||||
{:reply, {:ok, unsold_items, state.meso}, %{state | open: false, available: false}}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call(:get_meso, _from, state) do
|
||||
{:reply, state.meso, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:set_meso, meso}, _from, state) do
|
||||
{:reply, :ok, %{state | meso: meso}}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_cast({:broadcast, packet, include_owner}, state) do
|
||||
# Broadcast to all visitors
|
||||
Enum.each(state.visitors, fn {_id, data} ->
|
||||
send(data.pid, {:shop_packet, packet})
|
||||
end)
|
||||
|
||||
# Optionally broadcast to owner
|
||||
if include_owner do
|
||||
# Owner would receive via their own channel
|
||||
:ok
|
||||
end
|
||||
|
||||
{:noreply, state}
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Private Helper Functions
|
||||
# ============================================================================
|
||||
|
||||
defp via_tuple(shop_id) do
|
||||
{:via, Registry, {Odinsea.ShopRegistry, shop_id}}
|
||||
end
|
||||
|
||||
defp generate_id do
|
||||
:erlang.unique_integer([:positive])
|
||||
end
|
||||
|
||||
defp find_free_slot(state) do
|
||||
used_slots = Map.values(state.visitors) |> Enum.map(& &1.slot)
|
||||
|
||||
Enum.find(1..@max_visitors, fn slot ->
|
||||
slot not in used_slots
|
||||
end)
|
||||
end
|
||||
|
||||
defp get_slot_for_character(state, character_id) do
|
||||
cond do
|
||||
character_id == state.owner_id ->
|
||||
0
|
||||
|
||||
true ->
|
||||
case Map.get(state.visitors, character_id) do
|
||||
nil -> -1
|
||||
data -> data.slot
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
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
|
||||
744
lib/odinsea/game/quest_action.ex
Normal file
744
lib/odinsea/game/quest_action.ex
Normal file
@@ -0,0 +1,744 @@
|
||||
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
|
||||
459
lib/odinsea/game/quest_progress.ex
Normal file
459
lib/odinsea/game/quest_progress.ex
Normal file
@@ -0,0 +1,459 @@
|
||||
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
|
||||
478
lib/odinsea/game/quest_requirement.ex
Normal file
478
lib/odinsea/game/quest_requirement.ex
Normal file
@@ -0,0 +1,478 @@
|
||||
defmodule Odinsea.Game.QuestRequirement do
|
||||
@moduledoc """
|
||||
Quest Requirement module - defines conditions for quest start/completion.
|
||||
|
||||
Requirements are checked when:
|
||||
- Starting a quest (start_requirements)
|
||||
- Completing a quest (complete_requirements)
|
||||
|
||||
## Requirement Types
|
||||
|
||||
- `:job` - Required job class
|
||||
- `:item` - Required items in inventory
|
||||
- `:quest` - Required quest completion status
|
||||
- `:lvmin` - Minimum level
|
||||
- `:lvmax` - Maximum level
|
||||
- `:mob` - Required mob kills
|
||||
- `:npc` - NPC to talk to
|
||||
- `:fieldEnter` - Enter specific map(s)
|
||||
- `:pop` - Minimum fame
|
||||
- `:interval` - Time interval for repeatable quests
|
||||
- `:skill` - Required skill level
|
||||
- `:pet` - Required pet
|
||||
- `:mbmin` - Monster book minimum cards
|
||||
- `:mbcard` - Specific monster book card level
|
||||
- `:questComplete` - Minimum number of completed quests
|
||||
- `:subJobFlags` - Sub-job flags (e.g., Dual Blade)
|
||||
- `:pettamenessmin` - Minimum pet closeness
|
||||
- `:partyQuest_S` - S-rank party quest completions
|
||||
- `:charmMin`, `:senseMin`, `:craftMin`, `:willMin`, `:charismaMin`, `:insightMin` - Trait minimums
|
||||
- `:dayByDay` - Daily quest
|
||||
- `:normalAutoStart` - Auto-start quest
|
||||
- `:startscript`, `:endscript` - Custom scripts
|
||||
"""
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
type: atom(),
|
||||
data: any()
|
||||
}
|
||||
|
||||
defstruct [:type, :data]
|
||||
|
||||
## Public API
|
||||
|
||||
@doc "Creates a new quest requirement"
|
||||
@spec new(atom(), any()) :: t()
|
||||
def new(type, data) do
|
||||
%__MODULE__{
|
||||
type: type,
|
||||
data: data
|
||||
}
|
||||
end
|
||||
|
||||
@doc "Builds a requirement 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")))
|
||||
data = parse_data(type, Map.get(map, :data, Map.get(map, "data", nil)))
|
||||
|
||||
%__MODULE__{
|
||||
type: type,
|
||||
data: data
|
||||
}
|
||||
end
|
||||
|
||||
@doc "Checks if a character meets this requirement"
|
||||
@spec check(t(), Odinsea.Game.Character.t()) :: boolean()
|
||||
def check(%__MODULE__{} = req, character) do
|
||||
do_check(req.type, req.data, character)
|
||||
end
|
||||
|
||||
@doc "Parses a WZ requirement 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
|
||||
"job" -> :job
|
||||
"item" -> :item
|
||||
"quest" -> :quest
|
||||
"lvmin" -> :lvmin
|
||||
"lvmax" -> :lvmax
|
||||
"end" -> :end
|
||||
"mob" -> :mob
|
||||
"npc" -> :npc
|
||||
"fieldenter" -> :fieldEnter
|
||||
"interval" -> :interval
|
||||
"startscript" -> :startscript
|
||||
"endscript" -> :endscript
|
||||
"pet" -> :pet
|
||||
"pettamenessmin" -> :pettamenessmin
|
||||
"mbmin" -> :mbmin
|
||||
"questcomplete" -> :questComplete
|
||||
"pop" -> :pop
|
||||
"skill" -> :skill
|
||||
"mbcard" -> :mbcard
|
||||
"subjobflags" -> :subJobFlags
|
||||
"daybyday" -> :dayByDay
|
||||
"normalautostart" -> :normalAutoStart
|
||||
"partyquest_s" -> :partyQuest_S
|
||||
"charmmin" -> :charmMin
|
||||
"sensemin" -> :senseMin
|
||||
"craftmin" -> :craftMin
|
||||
"willmin" -> :willMin
|
||||
"charismamin" -> :charismaMin
|
||||
"insightmin" -> :insightMin
|
||||
_ -> :undefined
|
||||
end
|
||||
end
|
||||
|
||||
## Private Functions
|
||||
|
||||
defp parse_data(:job, data) when is_list(data), do: data
|
||||
defp parse_data(:job, data) when is_integer(data), do: [data]
|
||||
defp parse_data(:job, data) when is_binary(data), do: [String.to_integer(data)]
|
||||
|
||||
defp parse_data(:item, data) when is_map(data), do: data
|
||||
defp parse_data(:item, data) when is_list(data) do
|
||||
Enum.reduce(data, %{}, fn item, acc ->
|
||||
item_id = Map.get(item, :id, Map.get(item, "id", 0))
|
||||
count = Map.get(item, :count, Map.get(item, "count", 1))
|
||||
Map.put(acc, item_id, count)
|
||||
end)
|
||||
end
|
||||
|
||||
defp parse_data(:quest, data) when is_map(data), do: data
|
||||
defp parse_data(:quest, data) when is_list(data) do
|
||||
Enum.reduce(data, %{}, fn quest, acc ->
|
||||
quest_id = Map.get(quest, :id, Map.get(quest, "id", 0))
|
||||
state = Map.get(quest, :state, Map.get(quest, "state", 0))
|
||||
Map.put(acc, quest_id, state)
|
||||
end)
|
||||
end
|
||||
|
||||
defp parse_data(:mob, data) when is_map(data), do: data
|
||||
defp parse_data(:mob, data) when is_list(data) do
|
||||
Enum.reduce(data, %{}, fn mob, acc ->
|
||||
mob_id = Map.get(mob, :id, Map.get(mob, "id", 0))
|
||||
count = Map.get(mob, :count, Map.get(mob, "count", 1))
|
||||
Map.put(acc, mob_id, count)
|
||||
end)
|
||||
end
|
||||
|
||||
defp parse_data(:lvmin, data) when is_integer(data), do: data
|
||||
defp parse_data(:lvmin, data) when is_binary(data), do: String.to_integer(data)
|
||||
|
||||
defp parse_data(:lvmax, data) when is_integer(data), do: data
|
||||
defp parse_data(:lvmax, data) when is_binary(data), do: String.to_integer(data)
|
||||
|
||||
defp parse_data(:npc, data) when is_integer(data), do: data
|
||||
defp parse_data(:npc, data) when is_binary(data), do: String.to_integer(data)
|
||||
|
||||
defp parse_data(:pop, data) when is_integer(data), do: data
|
||||
defp parse_data(:pop, data) when is_binary(data), do: String.to_integer(data)
|
||||
|
||||
defp parse_data(:interval, data) when is_integer(data), do: data
|
||||
defp parse_data(:interval, data) when is_binary(data), do: String.to_integer(data)
|
||||
|
||||
defp parse_data(:fieldEnter, data) when is_list(data), do: data
|
||||
defp parse_data(:fieldEnter, data) when is_integer(data), do: [data]
|
||||
defp parse_data(:fieldEnter, data) when is_binary(data) do
|
||||
case Integer.parse(data) do
|
||||
{int, _} -> [int]
|
||||
:error -> []
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_data(:questComplete, data) when is_integer(data), do: data
|
||||
defp parse_data(:questComplete, data) when is_binary(data), do: String.to_integer(data)
|
||||
|
||||
defp parse_data(:mbmin, data) when is_integer(data), do: data
|
||||
defp parse_data(:mbmin, data) when is_binary(data), do: String.to_integer(data)
|
||||
|
||||
defp parse_data(:pettamenessmin, data) when is_integer(data), do: data
|
||||
defp parse_data(:pettamenessmin, data) when is_binary(data), do: String.to_integer(data)
|
||||
|
||||
defp parse_data(:subJobFlags, data) when is_integer(data), do: data
|
||||
defp parse_data(:subJobFlags, data) when is_binary(data), do: String.to_integer(data)
|
||||
|
||||
defp parse_data(:skill, data) when is_list(data) do
|
||||
Enum.map(data, fn skill ->
|
||||
id = Map.get(skill, :id, Map.get(skill, "id", 0))
|
||||
acquire = Map.get(skill, :acquire, Map.get(skill, "acquire", 0))
|
||||
{id, acquire > 0}
|
||||
end)
|
||||
end
|
||||
|
||||
defp parse_data(:mbcard, data) when is_list(data) do
|
||||
Enum.map(data, fn card ->
|
||||
id = Map.get(card, :id, Map.get(card, "id", 0))
|
||||
min = Map.get(card, :min, Map.get(card, "min", 0))
|
||||
{id, min}
|
||||
end)
|
||||
end
|
||||
|
||||
defp parse_data(:pet, data) when is_list(data), do: data
|
||||
defp parse_data(:pet, data) when is_integer(data), do: [data]
|
||||
|
||||
defp parse_data(:startscript, data), do: to_string(data)
|
||||
defp parse_data(:endscript, data), do: to_string(data)
|
||||
defp parse_data(:end, data), do: to_string(data)
|
||||
|
||||
# Trait minimums
|
||||
defp parse_data(type, data) when type in [:charmMin, :senseMin, :craftMin, :willMin, :charismaMin, :insightMin] do
|
||||
if is_binary(data), do: String.to_integer(data), else: data
|
||||
end
|
||||
|
||||
defp parse_data(_type, data), do: data
|
||||
|
||||
# Requirement checking implementations
|
||||
|
||||
defp do_check(:job, required_jobs, character) do
|
||||
# Check if character's job is in the list of acceptable jobs
|
||||
character_job = Map.get(character, :job, 0)
|
||||
character_job in required_jobs || Map.get(character, :gm, false)
|
||||
end
|
||||
|
||||
defp do_check(:item, required_items, character) do
|
||||
# Check if character has required items
|
||||
# This is a simplified check - full implementation needs inventory lookup
|
||||
inventory = Map.get(character, :inventory, %{})
|
||||
|
||||
Enum.all?(required_items, fn {item_id, count} ->
|
||||
has_item_count(inventory, item_id, count)
|
||||
end)
|
||||
end
|
||||
|
||||
defp do_check(:quest, required_quests, character) do
|
||||
# Check quest completion status
|
||||
quest_progress = Map.get(character, :quest_progress, %{})
|
||||
|
||||
Enum.all?(required_quests, fn {quest_id, required_state} ->
|
||||
actual_state = Map.get(quest_progress, quest_id, 0)
|
||||
# State: 0 = not started, 1 = in progress, 2 = completed
|
||||
actual_state == required_state
|
||||
end)
|
||||
end
|
||||
|
||||
defp do_check(:lvmin, min_level, character) do
|
||||
Map.get(character, :level, 1) >= min_level
|
||||
end
|
||||
|
||||
defp do_check(:lvmax, max_level, character) do
|
||||
Map.get(character, :level, 1) <= max_level
|
||||
end
|
||||
|
||||
defp do_check(:mob, required_mobs, character) do
|
||||
# Check mob kill counts from quest progress
|
||||
mob_kills = Map.get(character, :quest_mob_kills, %{})
|
||||
|
||||
Enum.all?(required_mobs, fn {mob_id, count} ->
|
||||
Map.get(mob_kills, mob_id, 0) >= count
|
||||
end)
|
||||
end
|
||||
|
||||
defp do_check(:npc, npc_id, character) do
|
||||
# NPC check is usually done at runtime with the actual NPC ID
|
||||
# This is a placeholder that returns true
|
||||
true
|
||||
end
|
||||
|
||||
defp do_check(:npc, npc_id, character, talking_npc_id) do
|
||||
npc_id == talking_npc_id
|
||||
end
|
||||
|
||||
defp do_check(:fieldEnter, maps, character) do
|
||||
current_map = Map.get(character, :map_id, 0)
|
||||
current_map in maps
|
||||
end
|
||||
|
||||
defp do_check(:pop, min_fame, character) do
|
||||
Map.get(character, :fame, 0) >= min_fame
|
||||
end
|
||||
|
||||
defp do_check(:interval, interval_minutes, character) do
|
||||
# Check if enough time has passed for repeatable quest
|
||||
last_completion = Map.get(character, :last_quest_completion, %{})
|
||||
quest_id = Map.get(character, :checking_quest_id, 0)
|
||||
last_time = Map.get(last_completion, quest_id, 0)
|
||||
|
||||
if last_time == 0 do
|
||||
true
|
||||
else
|
||||
current_time = System.system_time(:second)
|
||||
(current_time - last_time) >= interval_minutes * 60
|
||||
end
|
||||
end
|
||||
|
||||
defp do_check(:questComplete, min_completed, character) do
|
||||
completed_count =
|
||||
character
|
||||
|> Map.get(:quest_progress, %{})
|
||||
|> Enum.count(fn {_id, state} -> state == 2 end)
|
||||
|
||||
completed_count >= min_completed
|
||||
end
|
||||
|
||||
defp do_check(:mbmin, min_cards, character) do
|
||||
# Monster book card count check
|
||||
monster_book = Map.get(character, :monster_book, %{})
|
||||
card_count = map_size(monster_book)
|
||||
card_count >= min_cards
|
||||
end
|
||||
|
||||
defp do_check(:skill, required_skills, character) do
|
||||
skills = Map.get(character, :skills, %{})
|
||||
|
||||
Enum.all?(required_skills, fn {skill_id, should_have} ->
|
||||
skill_level = Map.get(skills, skill_id, 0)
|
||||
master_level = Map.get(character, :skill_master_levels, %{}) |> Map.get(skill_id, 0)
|
||||
|
||||
has_skill = skill_level > 0 || master_level > 0
|
||||
|
||||
if should_have do
|
||||
has_skill
|
||||
else
|
||||
not has_skill
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp do_check(:pet, pet_ids, character) do
|
||||
pets = Map.get(character, :pets, [])
|
||||
|
||||
Enum.any?(pet_ids, fn pet_id ->
|
||||
Enum.any?(pets, fn pet ->
|
||||
Map.get(pet, :item_id) == pet_id && Map.get(pet, :summoned, false)
|
||||
end)
|
||||
end)
|
||||
end
|
||||
|
||||
defp do_check(:pettamenessmin, min_closeness, character) do
|
||||
pets = Map.get(character, :pets, [])
|
||||
|
||||
Enum.any?(pets, fn pet ->
|
||||
Map.get(pet, :summoned, false) && Map.get(pet, :closeness, 0) >= min_closeness
|
||||
end)
|
||||
end
|
||||
|
||||
defp do_check(:subJobFlags, flags, character) do
|
||||
subcategory = Map.get(character, :subcategory, 0)
|
||||
# Sub-job flags check (used for Dual Blade, etc.)
|
||||
subcategory == div(flags, 2)
|
||||
end
|
||||
|
||||
defp do_check(:mbcard, required_cards, character) do
|
||||
monster_book = Map.get(character, :monster_book, %{})
|
||||
|
||||
Enum.all?(required_cards, fn {card_id, min_level} ->
|
||||
Map.get(monster_book, card_id, 0) >= min_level
|
||||
end)
|
||||
end
|
||||
|
||||
defp do_check(:dayByDay, _data, _character) do
|
||||
# Daily quest - handled separately
|
||||
true
|
||||
end
|
||||
|
||||
defp do_check(:normalAutoStart, _data, _character) do
|
||||
# Auto-start flag
|
||||
true
|
||||
end
|
||||
|
||||
defp do_check(:partyQuest_S, _data, character) do
|
||||
# S-rank party quest check - simplified
|
||||
# Real implementation would check character's PQ history
|
||||
true
|
||||
end
|
||||
|
||||
# Trait minimum checks
|
||||
defp do_check(trait_type, min_level, character) when trait_type in [:charmMin, :senseMin, :craftMin, :willMin, :charismaMin, :insightMin] do
|
||||
trait_name =
|
||||
case trait_type do
|
||||
:charmMin -> :charm
|
||||
:senseMin -> :sense
|
||||
:craftMin -> :craft
|
||||
:willMin -> :will
|
||||
:charismaMin -> :charisma
|
||||
:insightMin -> :insight
|
||||
end
|
||||
|
||||
traits = Map.get(character, :traits, %{})
|
||||
trait_level = Map.get(traits, trait_name, 0)
|
||||
trait_level >= min_level
|
||||
end
|
||||
|
||||
defp do_check(:end, time_str, _character) do
|
||||
# Event end time check
|
||||
if time_str == nil || time_str == "" do
|
||||
true
|
||||
else
|
||||
# Parse YYYYMMDDHH format
|
||||
case String.length(time_str) do
|
||||
10 ->
|
||||
year = String.slice(time_str, 0, 4) |> String.to_integer()
|
||||
month = String.slice(time_str, 4, 2) |> String.to_integer()
|
||||
day = String.slice(time_str, 6, 2) |> String.to_integer()
|
||||
hour = String.slice(time_str, 8, 2) |> String.to_integer()
|
||||
|
||||
end_time = NaiveDateTime.new!(year, month, day, hour, 0, 0)
|
||||
now = NaiveDateTime.utc_now()
|
||||
|
||||
NaiveDateTime.compare(now, end_time) == :lt
|
||||
|
||||
_ ->
|
||||
true
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp do_check(:startscript, _script, _character), do: true
|
||||
defp do_check(:endscript, _script, _character), do: true
|
||||
|
||||
defp do_check(:undefined, _data, _character), do: true
|
||||
|
||||
defp do_check(_type, _data, _character), do: true
|
||||
|
||||
# Helper functions
|
||||
|
||||
defp has_item_count(inventory, item_id, required_count) when required_count > 0 do
|
||||
# Count items across all inventory types
|
||||
total =
|
||||
inventory
|
||||
|> Map.values()
|
||||
|> Enum.flat_map(& &1)
|
||||
|> Enum.filter(fn item -> Map.get(item, :item_id) == item_id end)
|
||||
|> Enum.map(fn item -> Map.get(item, :quantity, 1) end)
|
||||
|> Enum.sum()
|
||||
|
||||
total >= required_count
|
||||
end
|
||||
|
||||
defp has_item_count(inventory, item_id, required_count) when required_count <= 0 do
|
||||
# For negative counts (checking we DON'T have too many)
|
||||
total =
|
||||
inventory
|
||||
|> Map.values()
|
||||
|> Enum.flat_map(& &1)
|
||||
|> Enum.filter(fn item -> Map.get(item, :item_id) == item_id end)
|
||||
|> Enum.map(fn item -> Map.get(item, :quantity, 1) end)
|
||||
|> Enum.sum()
|
||||
|
||||
# If required_count is 0 or negative, we should have 0 of the item
|
||||
# or specifically, not more than the absolute value
|
||||
total <= abs(required_count)
|
||||
end
|
||||
|
||||
@doc "Checks if an item should show in drop for this quest"
|
||||
@spec shows_drop?(t(), integer(), Odinsea.Game.Character.t()) :: boolean()
|
||||
def shows_drop?(%__MODULE__{type: :item} = req, item_id, character) do
|
||||
# Check if this item is needed for the quest and should be shown in drops
|
||||
required_items = req.data
|
||||
|
||||
case Map.get(required_items, item_id) do
|
||||
nil ->
|
||||
false
|
||||
|
||||
required_count ->
|
||||
# Check if player still needs more of this item
|
||||
inventory = Map.get(character, :inventory, %{})
|
||||
current_count = count_items(inventory, item_id)
|
||||
|
||||
# Show drop if player needs more (required > current)
|
||||
# or if required_count is 0/negative (special case)
|
||||
current_count < required_count || required_count <= 0
|
||||
end
|
||||
end
|
||||
|
||||
def shows_drop?(_req, _item_id, _character), do: false
|
||||
|
||||
defp count_items(inventory, item_id) do
|
||||
inventory
|
||||
|> Map.values()
|
||||
|> Enum.flat_map(& &1)
|
||||
|> Enum.filter(fn item -> Map.get(item, :item_id) == item_id end)
|
||||
|> Enum.map(fn item -> Map.get(item, :quantity, 1) end)
|
||||
|> Enum.sum()
|
||||
end
|
||||
end
|
||||
284
lib/odinsea/game/reactor.ex
Normal file
284
lib/odinsea/game/reactor.ex
Normal file
@@ -0,0 +1,284 @@
|
||||
defmodule Odinsea.Game.Reactor do
|
||||
@moduledoc """
|
||||
Represents a reactor instance on a map.
|
||||
|
||||
Reactors are map objects (boxes, rocks, plants) that can be hit/activated by players.
|
||||
They have states, can drop items, trigger scripts, and respawn after time.
|
||||
|
||||
Ported from Java: src/server/maps/MapleReactor.java
|
||||
"""
|
||||
|
||||
alias Odinsea.Game.ReactorStats
|
||||
|
||||
@typedoc "Reactor instance struct"
|
||||
@type t :: %__MODULE__{
|
||||
# Identity
|
||||
oid: integer() | nil, # Object ID (assigned by map)
|
||||
reactor_id: integer(), # Reactor template ID
|
||||
|
||||
# State
|
||||
state: integer(), # Current state (byte)
|
||||
alive: boolean(), # Whether reactor is active
|
||||
timer_active: boolean(), # Whether timeout timer is running
|
||||
|
||||
# Position
|
||||
x: integer(), # X position
|
||||
y: integer(), # Y position
|
||||
facing_direction: integer(), # Facing direction (0 or 1)
|
||||
|
||||
# Properties
|
||||
name: String.t(), # Reactor name
|
||||
delay: integer(), # Respawn delay in milliseconds
|
||||
custom: boolean(), # Custom spawned (not from template)
|
||||
|
||||
# Stats reference
|
||||
stats: ReactorStats.t() | nil # Template stats
|
||||
}
|
||||
|
||||
defstruct [
|
||||
:oid,
|
||||
:reactor_id,
|
||||
:stats,
|
||||
state: 0,
|
||||
alive: true,
|
||||
timer_active: false,
|
||||
x: 0,
|
||||
y: 0,
|
||||
facing_direction: 0,
|
||||
name: "",
|
||||
delay: -1,
|
||||
custom: false
|
||||
]
|
||||
|
||||
@doc """
|
||||
Creates a new reactor instance from template stats.
|
||||
"""
|
||||
@spec new(integer(), ReactorStats.t()) :: t()
|
||||
def new(reactor_id, stats) do
|
||||
%__MODULE__{
|
||||
reactor_id: reactor_id,
|
||||
stats: stats
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Creates a copy of a reactor (for respawning).
|
||||
"""
|
||||
@spec copy(t()) :: t()
|
||||
def copy(reactor) do
|
||||
%__MODULE__{
|
||||
reactor_id: reactor.reactor_id,
|
||||
stats: reactor.stats,
|
||||
state: 0,
|
||||
alive: true,
|
||||
timer_active: false,
|
||||
x: reactor.x,
|
||||
y: reactor.y,
|
||||
facing_direction: reactor.facing_direction,
|
||||
name: reactor.name,
|
||||
delay: reactor.delay,
|
||||
custom: reactor.custom
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Sets the reactor's object ID.
|
||||
"""
|
||||
@spec set_oid(t(), integer()) :: t()
|
||||
def set_oid(reactor, oid) do
|
||||
%{reactor | oid: oid}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Sets the reactor's position.
|
||||
"""
|
||||
@spec set_position(t(), integer(), integer()) :: t()
|
||||
def set_position(reactor, x, y) do
|
||||
%{reactor | x: x, y: y}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Sets the reactor's state.
|
||||
"""
|
||||
@spec set_state(t(), integer()) :: t()
|
||||
def set_state(reactor, state) do
|
||||
%{reactor | state: state}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Sets whether the reactor is alive.
|
||||
"""
|
||||
@spec set_alive(t(), boolean()) :: t()
|
||||
def set_alive(reactor, alive) do
|
||||
%{reactor | alive: alive}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Sets the facing direction.
|
||||
"""
|
||||
@spec set_facing_direction(t(), integer()) :: t()
|
||||
def set_facing_direction(reactor, direction) do
|
||||
%{reactor | facing_direction: direction}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Sets the reactor name.
|
||||
"""
|
||||
@spec set_name(t(), String.t()) :: t()
|
||||
def set_name(reactor, name) do
|
||||
%{reactor | name: name}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Sets the respawn delay.
|
||||
"""
|
||||
@spec set_delay(t(), integer()) :: t()
|
||||
def set_delay(reactor, delay) do
|
||||
%{reactor | delay: delay}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Sets whether this is a custom reactor.
|
||||
"""
|
||||
@spec set_custom(t(), boolean()) :: t()
|
||||
def set_custom(reactor, custom) do
|
||||
%{reactor | custom: custom}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Sets timer active status.
|
||||
"""
|
||||
@spec set_timer_active(t(), boolean()) :: t()
|
||||
def set_timer_active(reactor, active) do
|
||||
%{reactor | timer_active: active}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the reactor type for the current state.
|
||||
Returns the type value or -1 if stats not loaded.
|
||||
"""
|
||||
@spec get_type(t()) :: integer()
|
||||
def get_type(reactor) do
|
||||
if reactor.stats do
|
||||
ReactorStats.get_type(reactor.stats, reactor.state)
|
||||
else
|
||||
-1
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the next state for the current state.
|
||||
"""
|
||||
@spec get_next_state(t()) :: integer()
|
||||
def get_next_state(reactor) do
|
||||
if reactor.stats do
|
||||
ReactorStats.get_next_state(reactor.stats, reactor.state)
|
||||
else
|
||||
-1
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the timeout for the current state.
|
||||
"""
|
||||
@spec get_timeout(t()) :: integer()
|
||||
def get_timeout(reactor) do
|
||||
if reactor.stats do
|
||||
ReactorStats.get_timeout(reactor.stats, reactor.state)
|
||||
else
|
||||
-1
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the touch mode for the current state.
|
||||
Returns: 0 = hit only, 1 = click/touch, 2 = touch only
|
||||
"""
|
||||
@spec can_touch(t()) :: integer()
|
||||
def can_touch(reactor) do
|
||||
if reactor.stats do
|
||||
ReactorStats.can_touch(reactor.stats, reactor.state)
|
||||
else
|
||||
0
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the required item to react for the current state.
|
||||
Returns {item_id, quantity} or nil.
|
||||
"""
|
||||
@spec get_react_item(t()) :: {integer(), integer()} | nil
|
||||
def get_react_item(reactor) do
|
||||
if reactor.stats do
|
||||
ReactorStats.get_react_item(reactor.stats, reactor.state)
|
||||
else
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Advances to the next state.
|
||||
Returns the updated reactor.
|
||||
"""
|
||||
@spec advance_state(t()) :: t()
|
||||
def advance_state(reactor) do
|
||||
next_state = get_next_state(reactor)
|
||||
if next_state >= 0 do
|
||||
set_state(reactor, next_state)
|
||||
else
|
||||
reactor
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if the reactor should trigger a script for the current state.
|
||||
"""
|
||||
@spec should_trigger_script?(t()) :: boolean()
|
||||
def should_trigger_script?(reactor) do
|
||||
type = get_type(reactor)
|
||||
# Type < 100 or type == 999 typically trigger scripts
|
||||
type < 100 or type == 999
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if this reactor is in a looping state (state == next_state).
|
||||
"""
|
||||
@spec is_looping?(t()) :: boolean()
|
||||
def is_looping?(reactor) do
|
||||
reactor.state == get_next_state(reactor)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if the reactor should be destroyed (next state is -1 or final state).
|
||||
"""
|
||||
@spec should_destroy?(t()) :: boolean()
|
||||
def should_destroy?(reactor) do
|
||||
next = get_next_state(reactor)
|
||||
next == -1 or get_type(reactor) == 999
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the reactor's area of effect (hit box).
|
||||
Returns {tl_x, tl_y, br_x, br_y} or nil if not defined.
|
||||
"""
|
||||
@spec get_area(t()) :: {integer(), integer(), integer(), integer()} | nil
|
||||
def get_area(reactor) do
|
||||
if reactor.stats and reactor.stats.tl and reactor.stats.br do
|
||||
{reactor.stats.tl.x, reactor.stats.tl.y, reactor.stats.br.x, reactor.stats.br.y}
|
||||
else
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Resets the reactor to initial state (for respawning).
|
||||
"""
|
||||
@spec reset(t()) :: t()
|
||||
def reset(reactor) do
|
||||
%{reactor |
|
||||
state: 0,
|
||||
alive: true,
|
||||
timer_active: false
|
||||
}
|
||||
end
|
||||
end
|
||||
276
lib/odinsea/game/reactor_factory.ex
Normal file
276
lib/odinsea/game/reactor_factory.ex
Normal file
@@ -0,0 +1,276 @@
|
||||
defmodule Odinsea.Game.ReactorFactory do
|
||||
@moduledoc """
|
||||
Reactor Factory - loads and caches reactor template data.
|
||||
|
||||
This module loads reactor metadata (states, types, items, timeouts) from cached JSON files.
|
||||
The JSON files should be exported from the Java server's WZ data providers.
|
||||
|
||||
Reactor data is cached in ETS for fast lookups.
|
||||
|
||||
Ported from Java: src/server/maps/MapleReactorFactory.java
|
||||
"""
|
||||
|
||||
use GenServer
|
||||
require Logger
|
||||
|
||||
alias Odinsea.Game.{Reactor, ReactorStats}
|
||||
|
||||
# ETS table name
|
||||
@reactor_stats :odinsea_reactor_stats
|
||||
|
||||
# Data file path
|
||||
@reactor_data_file "data/reactors.json"
|
||||
|
||||
## Public API
|
||||
|
||||
@doc "Starts the ReactorFactory GenServer"
|
||||
def start_link(opts \\ []) do
|
||||
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets reactor stats by reactor ID.
|
||||
Returns nil if not found.
|
||||
"""
|
||||
@spec get_reactor_stats(integer()) :: ReactorStats.t() | nil
|
||||
def get_reactor_stats(reactor_id) do
|
||||
case :ets.lookup(@reactor_stats, reactor_id) do
|
||||
[{^reactor_id, stats}] -> stats
|
||||
[] -> nil
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a reactor instance by ID.
|
||||
Returns nil if stats not found.
|
||||
"""
|
||||
@spec get_reactor(integer()) :: Reactor.t() | nil
|
||||
def get_reactor(reactor_id) do
|
||||
case get_reactor_stats(reactor_id) do
|
||||
nil -> nil
|
||||
stats -> Reactor.new(reactor_id, stats)
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Creates a reactor instance with position and properties.
|
||||
"""
|
||||
@spec create_reactor(integer(), integer(), integer(), integer(), String.t(), integer()) :: Reactor.t() | nil
|
||||
def create_reactor(reactor_id, x, y, facing_direction \\ 0, name \\ "", delay \\ -1) do
|
||||
case get_reactor_stats(reactor_id) do
|
||||
nil ->
|
||||
Logger.warning("Reactor stats not found for reactor_id=#{reactor_id}")
|
||||
nil
|
||||
|
||||
stats ->
|
||||
%Reactor{
|
||||
reactor_id: reactor_id,
|
||||
stats: stats,
|
||||
x: x,
|
||||
y: y,
|
||||
facing_direction: facing_direction,
|
||||
name: name,
|
||||
delay: delay,
|
||||
state: 0,
|
||||
alive: true,
|
||||
timer_active: false,
|
||||
custom: false
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if reactor stats exist.
|
||||
"""
|
||||
@spec reactor_exists?(integer()) :: boolean()
|
||||
def reactor_exists?(reactor_id) do
|
||||
:ets.member(@reactor_stats, reactor_id)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets all loaded reactor IDs.
|
||||
"""
|
||||
@spec get_all_reactor_ids() :: [integer()]
|
||||
def get_all_reactor_ids do
|
||||
:ets.select(@reactor_stats, [{{:"$1", :_}, [], [:"$1"]}])
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the number of loaded reactors.
|
||||
"""
|
||||
@spec get_reactor_count() :: integer()
|
||||
def get_reactor_count do
|
||||
:ets.info(@reactor_stats, :size)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Reloads reactor data from files.
|
||||
"""
|
||||
def reload do
|
||||
GenServer.call(__MODULE__, :reload, :infinity)
|
||||
end
|
||||
|
||||
## GenServer Callbacks
|
||||
|
||||
@impl true
|
||||
def init(_opts) do
|
||||
# Create ETS table
|
||||
:ets.new(@reactor_stats, [:set, :public, :named_table, read_concurrency: true])
|
||||
|
||||
# Load data
|
||||
load_reactor_data()
|
||||
|
||||
{:ok, %{}}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call(:reload, _from, state) do
|
||||
Logger.info("Reloading reactor data...")
|
||||
load_reactor_data()
|
||||
{:reply, :ok, state}
|
||||
end
|
||||
|
||||
## Private Functions
|
||||
|
||||
defp load_reactor_data do
|
||||
priv_dir = :code.priv_dir(:odinsea) |> to_string()
|
||||
file_path = Path.join(priv_dir, @reactor_data_file)
|
||||
|
||||
load_reactors_from_file(file_path)
|
||||
|
||||
count = :ets.info(@reactor_stats, :size)
|
||||
Logger.info("Loaded #{count} reactor templates")
|
||||
end
|
||||
|
||||
defp load_reactors_from_file(file_path) do
|
||||
case File.read(file_path) do
|
||||
{:ok, content} ->
|
||||
case Jason.decode(content) do
|
||||
{:ok, reactors} when is_map(reactors) ->
|
||||
# Clear existing data
|
||||
:ets.delete_all_objects(@reactor_stats)
|
||||
|
||||
# Load reactors and handle links
|
||||
links = process_reactors(reactors, %{})
|
||||
|
||||
# Resolve links
|
||||
resolve_links(links)
|
||||
|
||||
:ok
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warning("Failed to parse reactors JSON: #{inspect(reason)}")
|
||||
create_fallback_reactors()
|
||||
end
|
||||
|
||||
{:error, :enoent} ->
|
||||
Logger.warning("Reactors file not found: #{file_path}, using fallback data")
|
||||
create_fallback_reactors()
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("Failed to read reactors: #{inspect(reason)}")
|
||||
create_fallback_reactors()
|
||||
end
|
||||
end
|
||||
|
||||
defp process_reactors(reactors, links) do
|
||||
Enum.reduce(reactors, links, fn {reactor_id_str, reactor_data}, acc_links ->
|
||||
reactor_id = String.to_integer(reactor_id_str)
|
||||
|
||||
# Check if this is a link to another reactor
|
||||
link_target = reactor_data["link"]
|
||||
|
||||
if link_target && link_target > 0 do
|
||||
# Store link for later resolution
|
||||
Map.put(acc_links, reactor_id, link_target)
|
||||
else
|
||||
# Build stats from data
|
||||
stats = ReactorStats.from_json(reactor_data)
|
||||
:ets.insert(@reactor_stats, {reactor_id, stats})
|
||||
acc_links
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp resolve_links(links) do
|
||||
Enum.each(links, fn {reactor_id, target_id} ->
|
||||
case :ets.lookup(@reactor_stats, target_id) do
|
||||
[{^target_id, target_stats}] ->
|
||||
# Copy target stats for linked reactor
|
||||
:ets.insert(@reactor_stats, {reactor_id, target_stats})
|
||||
|
||||
[] ->
|
||||
Logger.warning("Link target not found: #{target_id} for reactor #{reactor_id}")
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
# Fallback data for basic testing
|
||||
defp create_fallback_reactors do
|
||||
# Common reactors from MapleStory
|
||||
fallback_reactors = [
|
||||
%{
|
||||
reactor_id: 100000, # Normal box
|
||||
states: %{
|
||||
"0" => %{type: 999, next_state: -1, timeout: -1, can_touch: 0},
|
||||
"1" => %{type: 999, next_state: -1, timeout: -1, can_touch: 0}
|
||||
}
|
||||
},
|
||||
%{
|
||||
reactor_id: 200000, # Herb
|
||||
activate_by_touch: true,
|
||||
states: %{
|
||||
"0" => %{type: 2, next_state: 1, timeout: -1, can_touch: 2},
|
||||
"1" => %{type: 999, next_state: -1, timeout: -1, can_touch: 0}
|
||||
}
|
||||
},
|
||||
%{
|
||||
reactor_id: 200100, # Vein
|
||||
activate_by_touch: true,
|
||||
states: %{
|
||||
"0" => %{type: 2, next_state: 1, timeout: -1, can_touch: 2},
|
||||
"1" => %{type: 999, next_state: -1, timeout: -1, can_touch: 0}
|
||||
}
|
||||
},
|
||||
%{
|
||||
reactor_id: 200200, # Gold Flower
|
||||
activate_by_touch: true,
|
||||
states: %{
|
||||
"0" => %{type: 2, next_state: 1, timeout: -1, can_touch: 2},
|
||||
"1" => %{type: 999, next_state: -1, timeout: -1, can_touch: 0}
|
||||
}
|
||||
},
|
||||
%{
|
||||
reactor_id: 200300, # Silver Flower
|
||||
activate_by_touch: true,
|
||||
states: %{
|
||||
"0" => %{type: 2, next_state: 1, timeout: -1, can_touch: 2},
|
||||
"1" => %{type: 999, next_state: -1, timeout: -1, can_touch: 0}
|
||||
}
|
||||
},
|
||||
%{
|
||||
reactor_id: 100011, # Mysterious Herb
|
||||
activate_by_touch: true,
|
||||
states: %{
|
||||
"0" => %{type: 2, next_state: 1, timeout: -1, can_touch: 2},
|
||||
"1" => %{type: 999, next_state: -1, timeout: -1, can_touch: 0}
|
||||
}
|
||||
},
|
||||
%{
|
||||
reactor_id: 200011, # Mysterious Vein
|
||||
activate_by_touch: true,
|
||||
states: %{
|
||||
"0" => %{type: 2, next_state: 1, timeout: -1, can_touch: 2},
|
||||
"1" => %{type: 999, next_state: -1, timeout: -1, can_touch: 0}
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
Enum.each(fallback_reactors, fn reactor_data ->
|
||||
stats = ReactorStats.from_json(reactor_data)
|
||||
:ets.insert(@reactor_stats, {reactor_data.reactor_id, stats})
|
||||
end)
|
||||
|
||||
Logger.info("Created #{length(fallback_reactors)} fallback reactor templates")
|
||||
end
|
||||
end
|
||||
252
lib/odinsea/game/reactor_stats.ex
Normal file
252
lib/odinsea/game/reactor_stats.ex
Normal file
@@ -0,0 +1,252 @@
|
||||
defmodule Odinsea.Game.ReactorStats do
|
||||
@moduledoc """
|
||||
Represents reactor template stats (state machine data).
|
||||
|
||||
Contains the state definitions for a reactor type.
|
||||
Each state defines: type, next state, required item, timeout, touch mode.
|
||||
|
||||
Ported from Java: src/server/maps/MapleReactorStats.java
|
||||
"""
|
||||
|
||||
defmodule Point do
|
||||
@moduledoc "Simple 2D point for area bounds"
|
||||
@type t :: %__MODULE__{x: integer(), y: integer()}
|
||||
defstruct [:x, :y]
|
||||
end
|
||||
|
||||
defmodule StateData do
|
||||
@moduledoc "State definition for a reactor"
|
||||
@type t :: %__MODULE__{
|
||||
type: integer(), # State type (determines behavior)
|
||||
next_state: integer(), # Next state index (-1 = end)
|
||||
react_item: {integer(), integer()} | nil, # {item_id, quantity} required
|
||||
timeout: integer(), # Timeout in ms before auto-advance (-1 = none)
|
||||
can_touch: integer() # 0 = hit only, 1 = click/touch, 2 = touch only
|
||||
}
|
||||
defstruct [
|
||||
:type,
|
||||
:next_state,
|
||||
:react_item,
|
||||
timeout: -1,
|
||||
can_touch: 0
|
||||
]
|
||||
end
|
||||
|
||||
@typedoc "Reactor stats struct"
|
||||
@type t :: %__MODULE__{
|
||||
tl: Point.t() | nil, # Top-left corner of area (for item-triggered)
|
||||
br: Point.t() | nil, # Bottom-right corner of area
|
||||
states: %{integer() => StateData.t()}, # State definitions by state number
|
||||
activate_by_touch: boolean() # Whether reactor activates by touch
|
||||
}
|
||||
|
||||
defstruct [
|
||||
:tl,
|
||||
:br,
|
||||
states: %{},
|
||||
activate_by_touch: false
|
||||
]
|
||||
|
||||
@doc """
|
||||
Creates a new empty reactor stats.
|
||||
"""
|
||||
@spec new() :: t()
|
||||
def new do
|
||||
%__MODULE__{}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Sets the top-left point of the area.
|
||||
"""
|
||||
@spec set_tl(t(), integer(), integer()) :: t()
|
||||
def set_tl(stats, x, y) do
|
||||
%{stats | tl: %Point{x: x, y: y}}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Sets the bottom-right point of the area.
|
||||
"""
|
||||
@spec set_br(t(), integer(), integer()) :: t()
|
||||
def set_br(stats, x, y) do
|
||||
%{stats | br: %Point{x: x, y: y}}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Sets whether reactor activates by touch.
|
||||
"""
|
||||
@spec set_activate_by_touch(t(), boolean()) :: t()
|
||||
def set_activate_by_touch(stats, activate) do
|
||||
%{stats | activate_by_touch: activate}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Adds a state definition.
|
||||
|
||||
## Parameters
|
||||
- stats: the reactor stats struct
|
||||
- state_num: the state number (byte value)
|
||||
- type: the state type (determines behavior)
|
||||
- react_item: {item_id, quantity} or nil
|
||||
- next_state: the next state number (-1 for end)
|
||||
- timeout: timeout in ms (-1 for none)
|
||||
- can_touch: 0 = hit only, 1 = click, 2 = touch only
|
||||
"""
|
||||
@spec add_state(
|
||||
t(),
|
||||
integer(),
|
||||
integer(),
|
||||
{integer(), integer()} | nil,
|
||||
integer(),
|
||||
integer(),
|
||||
integer()
|
||||
) :: t()
|
||||
def add_state(stats, state_num, type, react_item, next_state, timeout, can_touch) do
|
||||
state_data = %StateData{
|
||||
type: type,
|
||||
react_item: react_item,
|
||||
next_state: next_state,
|
||||
timeout: timeout,
|
||||
can_touch: can_touch
|
||||
}
|
||||
|
||||
%{stats | states: Map.put(stats.states, state_num, state_data)}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the next state for a given current state.
|
||||
Returns -1 if not found.
|
||||
"""
|
||||
@spec get_next_state(t(), integer()) :: integer()
|
||||
def get_next_state(stats, state) do
|
||||
case Map.get(stats.states, state) do
|
||||
nil -> -1
|
||||
state_data -> state_data.next_state
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the type for a given state.
|
||||
Returns -1 if not found.
|
||||
"""
|
||||
@spec get_type(t(), integer()) :: integer()
|
||||
def get_type(stats, state) do
|
||||
case Map.get(stats.states, state) do
|
||||
nil -> -1
|
||||
state_data -> state_data.type
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the react item for a given state.
|
||||
Returns nil if not found.
|
||||
"""
|
||||
@spec get_react_item(t(), integer()) :: {integer(), integer()} | nil
|
||||
def get_react_item(stats, state) do
|
||||
case Map.get(stats.states, state) do
|
||||
nil -> nil
|
||||
state_data -> state_data.react_item
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the timeout for a given state.
|
||||
Returns -1 if not found.
|
||||
"""
|
||||
@spec get_timeout(t(), integer()) :: integer()
|
||||
def get_timeout(stats, state) do
|
||||
case Map.get(stats.states, state) do
|
||||
nil -> -1
|
||||
state_data -> state_data.timeout
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the touch mode for a given state.
|
||||
Returns 0 if not found.
|
||||
|
||||
Modes:
|
||||
- 0: Hit only (weapon attack)
|
||||
- 1: Click/touch (interact button)
|
||||
- 2: Touch only (walk into)
|
||||
"""
|
||||
@spec can_touch(t(), integer()) :: integer()
|
||||
def can_touch(stats, state) do
|
||||
case Map.get(stats.states, state) do
|
||||
nil -> 0
|
||||
state_data -> state_data.can_touch
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets all state numbers defined for this reactor.
|
||||
"""
|
||||
@spec get_state_numbers(t()) :: [integer()]
|
||||
def get_state_numbers(stats) do
|
||||
Map.keys(stats.states) |> Enum.sort()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if a state exists.
|
||||
"""
|
||||
@spec has_state?(t(), integer()) :: boolean()
|
||||
def has_state?(stats, state) do
|
||||
Map.has_key?(stats.states, state)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the state data for a given state number.
|
||||
"""
|
||||
@spec get_state_data(t(), integer()) :: StateData.t() | nil
|
||||
def get_state_data(stats, state) do
|
||||
Map.get(stats.states, state)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Builds reactor stats from JSON data.
|
||||
"""
|
||||
@spec from_json(map()) :: t()
|
||||
def from_json(data) do
|
||||
stats = new()
|
||||
|
||||
# Set activate by touch
|
||||
stats = set_activate_by_touch(stats, data["activate_by_touch"] == true)
|
||||
|
||||
# Set area bounds if present
|
||||
stats =
|
||||
if data["tl"] do
|
||||
set_tl(stats, data["tl"]["x"] || 0, data["tl"]["y"] || 0)
|
||||
else
|
||||
stats
|
||||
end
|
||||
|
||||
stats =
|
||||
if data["br"] do
|
||||
set_br(stats, data["br"]["x"] || 0, data["br"]["y"] || 0)
|
||||
else
|
||||
stats
|
||||
end
|
||||
|
||||
# Add states
|
||||
states = data["states"] || %{}
|
||||
|
||||
Enum.reduce(states, stats, fn {state_num_str, state_data}, acc_stats ->
|
||||
state_num = String.to_integer(state_num_str)
|
||||
|
||||
type = state_data["type"] || 999
|
||||
next_state = state_data["next_state"] || -1
|
||||
timeout = state_data["timeout"] || -1
|
||||
can_touch = state_data["can_touch"] || 0
|
||||
|
||||
react_item =
|
||||
if state_data["react_item"] do
|
||||
item_id = state_data["react_item"]["item_id"]
|
||||
quantity = state_data["react_item"]["quantity"] || 1
|
||||
if item_id, do: {item_id, quantity}, else: nil
|
||||
else
|
||||
nil
|
||||
end
|
||||
|
||||
add_state(acc_stats, state_num, type, react_item, next_state, timeout, can_touch)
|
||||
end)
|
||||
end
|
||||
end
|
||||
112
lib/odinsea/game/shop_item.ex
Normal file
112
lib/odinsea/game/shop_item.ex
Normal file
@@ -0,0 +1,112 @@
|
||||
defmodule Odinsea.Game.ShopItem do
|
||||
@moduledoc """
|
||||
Represents an item listed in a player shop or hired merchant.
|
||||
Ported from src/server/shops/MaplePlayerShopItem.java
|
||||
|
||||
Each shop item contains:
|
||||
- The actual item data
|
||||
- Number of bundles (how many stacks)
|
||||
- Price per bundle
|
||||
"""
|
||||
|
||||
alias Odinsea.Game.{Item, Equip}
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
item: Item.t() | Equip.t(),
|
||||
bundles: integer(),
|
||||
price: integer()
|
||||
}
|
||||
|
||||
defstruct [
|
||||
:item,
|
||||
:bundles,
|
||||
:price
|
||||
]
|
||||
|
||||
@doc """
|
||||
Creates a new shop item.
|
||||
"""
|
||||
def new(item, bundles, price) do
|
||||
%__MODULE__{
|
||||
item: item,
|
||||
bundles: bundles,
|
||||
price: price
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Calculates the total quantity available (bundles * quantity per bundle).
|
||||
"""
|
||||
def total_quantity(%__MODULE__{} = shop_item) do
|
||||
per_bundle = shop_item.item.quantity
|
||||
shop_item.bundles * per_bundle
|
||||
end
|
||||
|
||||
@doc """
|
||||
Calculates the total price for a given quantity.
|
||||
"""
|
||||
def calculate_price(%__MODULE__{} = shop_item, quantity) do
|
||||
shop_item.price * quantity
|
||||
end
|
||||
|
||||
@doc """
|
||||
Reduces the number of bundles by the given quantity.
|
||||
Returns the updated shop item.
|
||||
"""
|
||||
def reduce_bundles(%__MODULE__{} = shop_item, quantity) do
|
||||
%{shop_item | bundles: shop_item.bundles - quantity}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if the item is sold out (no bundles remaining).
|
||||
"""
|
||||
def sold_out?(%__MODULE__{} = shop_item) do
|
||||
shop_item.bundles <= 0
|
||||
end
|
||||
|
||||
@doc """
|
||||
Creates a copy of the item for the buyer.
|
||||
The copy has the quantity adjusted based on bundles purchased.
|
||||
"""
|
||||
def create_buyer_item(%__MODULE__{} = shop_item, quantity) do
|
||||
item_copy = copy_item(shop_item.item)
|
||||
per_bundle = shop_item.item.quantity
|
||||
total_qty = quantity * per_bundle
|
||||
|
||||
case item_copy do
|
||||
%{quantity: _} = item ->
|
||||
%{item | quantity: total_qty}
|
||||
|
||||
equip ->
|
||||
# Equipment doesn't have quantity field
|
||||
equip
|
||||
end
|
||||
end
|
||||
|
||||
defp copy_item(%Item{} = item), do: Item.copy(item)
|
||||
defp copy_item(%Equip{} = equip), do: Equip.copy(equip)
|
||||
defp copy_item(item), do: item
|
||||
|
||||
@doc """
|
||||
Removes karma flags from an item (for trade).
|
||||
"""
|
||||
def remove_karma(%__MODULE__{} = shop_item) do
|
||||
item = shop_item.item
|
||||
|
||||
updated_item =
|
||||
cond do
|
||||
# KARMA_EQ flag = 0x02
|
||||
Bitwise.band(item.flag, 0x02) != 0 ->
|
||||
%{item | flag: item.flag - 0x02}
|
||||
|
||||
# KARMA_USE flag = 0x04
|
||||
Bitwise.band(item.flag, 0x04) != 0 ->
|
||||
%{item | flag: item.flag - 0x04}
|
||||
|
||||
true ->
|
||||
item
|
||||
end
|
||||
|
||||
%{shop_item | item: updated_item}
|
||||
end
|
||||
end
|
||||
271
lib/odinsea/game/skill.ex
Normal file
271
lib/odinsea/game/skill.ex
Normal file
@@ -0,0 +1,271 @@
|
||||
defmodule Odinsea.Game.Skill do
|
||||
@moduledoc """
|
||||
Skill struct and functions for MapleStory skills.
|
||||
|
||||
Ported from Java: client/Skill.java
|
||||
|
||||
Skills are abilities that characters can learn and use. Each skill has:
|
||||
- Multiple levels with increasing effects
|
||||
- Requirements (job, level, other skills)
|
||||
- Effects (buffs, damage, healing, etc.)
|
||||
- Animation data
|
||||
- Cooldowns and durations
|
||||
"""
|
||||
|
||||
alias Odinsea.Game.StatEffect
|
||||
|
||||
defstruct [
|
||||
:id,
|
||||
:name,
|
||||
:element,
|
||||
:max_level,
|
||||
:true_max,
|
||||
:master_level,
|
||||
:effects,
|
||||
:pvp_effects,
|
||||
:required_skills,
|
||||
:skill_type,
|
||||
:animation,
|
||||
:animation_time,
|
||||
:delay,
|
||||
:invisible,
|
||||
:time_limited,
|
||||
:combat_orders,
|
||||
:charge_skill,
|
||||
:magic,
|
||||
:caster_move,
|
||||
:push_target,
|
||||
:pull_target,
|
||||
:not_removed,
|
||||
:pvp_disabled,
|
||||
:event_taming_mob
|
||||
]
|
||||
|
||||
@type element :: :neutral | :fire | :ice | :lightning | :poison | :holy | :dark | :physical
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
id: integer(),
|
||||
name: String.t(),
|
||||
element: element(),
|
||||
max_level: integer(),
|
||||
true_max: integer(),
|
||||
master_level: integer(),
|
||||
effects: [StatEffect.t()],
|
||||
pvp_effects: [StatEffect.t()] | nil,
|
||||
required_skills: [{integer(), integer()}],
|
||||
skill_type: integer(),
|
||||
animation: [{String.t(), integer()}] | nil,
|
||||
animation_time: integer(),
|
||||
delay: integer(),
|
||||
invisible: boolean(),
|
||||
time_limited: boolean(),
|
||||
combat_orders: boolean(),
|
||||
charge_skill: boolean(),
|
||||
magic: boolean(),
|
||||
caster_move: boolean(),
|
||||
push_target: boolean(),
|
||||
pull_target: boolean(),
|
||||
not_removed: boolean(),
|
||||
pvp_disabled: boolean(),
|
||||
event_taming_mob: integer()
|
||||
}
|
||||
|
||||
@doc """
|
||||
Creates a new skill with the given ID and default values.
|
||||
"""
|
||||
@spec new(integer()) :: t()
|
||||
def new(id) do
|
||||
%__MODULE__{
|
||||
id: id,
|
||||
name: "",
|
||||
element: :neutral,
|
||||
max_level: 0,
|
||||
true_max: 0,
|
||||
master_level: 0,
|
||||
effects: [],
|
||||
pvp_effects: nil,
|
||||
required_skills: [],
|
||||
skill_type: 0,
|
||||
animation: nil,
|
||||
animation_time: 0,
|
||||
delay: 0,
|
||||
invisible: false,
|
||||
time_limited: false,
|
||||
combat_orders: false,
|
||||
charge_skill: false,
|
||||
magic: false,
|
||||
caster_move: false,
|
||||
push_target: false,
|
||||
pull_target: false,
|
||||
not_removed: false,
|
||||
pvp_disabled: false,
|
||||
event_taming_mob: 0
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the effect for a specific skill level.
|
||||
Returns the last effect if level exceeds max, or first effect if level <= 0.
|
||||
"""
|
||||
@spec get_effect(t(), integer()) :: StatEffect.t() | nil
|
||||
def get_effect(skill, level) do
|
||||
effects = skill.effects
|
||||
|
||||
cond do
|
||||
length(effects) == 0 -> nil
|
||||
level <= 0 -> List.first(effects)
|
||||
level > length(effects) -> List.last(effects)
|
||||
true -> Enum.at(effects, level - 1)
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the PVP effect for a specific skill level.
|
||||
Falls back to regular effects if PVP effects not defined.
|
||||
"""
|
||||
@spec get_pvp_effect(t(), integer()) :: StatEffect.t() | nil
|
||||
def get_pvp_effect(skill, level) do
|
||||
if skill.pvp_effects do
|
||||
cond do
|
||||
level <= 0 -> List.first(skill.pvp_effects)
|
||||
level > length(skill.pvp_effects) -> List.last(skill.pvp_effects)
|
||||
true -> Enum.at(skill.pvp_effects, level - 1)
|
||||
end
|
||||
else
|
||||
get_effect(skill, level)
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if this skill can be learned by a specific job.
|
||||
"""
|
||||
@spec can_be_learned_by?(t(), integer()) :: boolean()
|
||||
def can_be_learned_by?(skill, job_id) do
|
||||
skill_job = div(skill.id, 10000)
|
||||
|
||||
# Special job exceptions
|
||||
cond do
|
||||
# Evan beginner skills
|
||||
skill_job == 2001 -> is_evan_job?(job_id)
|
||||
# Regular beginner skills (adventurer)
|
||||
skill_job == 0 -> is_adventurer_job?(job_id)
|
||||
# Cygnus beginner skills
|
||||
skill_job == 1000 -> is_cygnus_job?(job_id)
|
||||
# Aran beginner skills
|
||||
skill_job == 2000 -> is_aran_job?(job_id)
|
||||
# Resistance beginner skills
|
||||
skill_job == 3000 -> is_resistance_job?(job_id)
|
||||
# Cannon shooter beginner
|
||||
skill_job == 1 -> is_cannon_job?(job_id)
|
||||
# Demon beginner
|
||||
skill_job == 3001 -> is_demon_job?(job_id)
|
||||
# Mercedes beginner
|
||||
skill_job == 2002 -> is_mercedes_job?(job_id)
|
||||
# Wrong job category
|
||||
div(job_id, 100) != div(skill_job, 100) -> false
|
||||
div(job_id, 1000) != div(skill_job, 1000) -> false
|
||||
# Class-specific restrictions
|
||||
is_cannon_job?(skill_job) and not is_cannon_job?(job_id) -> false
|
||||
is_demon_job?(skill_job) and not is_demon_job?(job_id) -> false
|
||||
is_adventurer_job?(skill_job) and not is_adventurer_job?(job_id) -> false
|
||||
is_cygnus_job?(skill_job) and not is_cygnus_job?(job_id) -> false
|
||||
is_aran_job?(skill_job) and not is_aran_job?(job_id) -> false
|
||||
is_evan_job?(skill_job) and not is_evan_job?(job_id) -> false
|
||||
is_mercedes_job?(skill_job) and not is_mercedes_job?(job_id) -> false
|
||||
is_resistance_job?(skill_job) and not is_resistance_job?(job_id) -> false
|
||||
# Wrong 2nd job
|
||||
rem(div(job_id, 10), 10) == 0 and rem(div(skill_job, 10), 10) > rem(div(job_id, 10), 10) -> false
|
||||
rem(div(skill_job, 10), 10) != 0 and rem(div(skill_job, 10), 10) != rem(div(job_id, 10), 10) -> false
|
||||
# Wrong 3rd/4th job
|
||||
rem(skill_job, 10) > rem(job_id, 10) -> false
|
||||
true -> true
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if this is a fourth job skill.
|
||||
"""
|
||||
@spec is_fourth_job?(t()) :: boolean()
|
||||
def is_fourth_job?(skill) do
|
||||
job_id = div(skill.id, 10000)
|
||||
|
||||
cond do
|
||||
# All 10 skills for 2312 (Phantom)
|
||||
job_id == 2312 -> true
|
||||
# Skills with max level <= 15 and no master level
|
||||
skill.max_level <= 15 and not skill.invisible and skill.master_level <= 0 -> false
|
||||
# Specific exceptions
|
||||
skill.id in [3_220_010, 3_120_011, 33_120_010, 32_120_009, 5_321_006, 21_120_011, 22_181_004, 4_340_010] -> false
|
||||
# Evan skills
|
||||
job_id >= 2212 and job_id < 3000 -> rem(job_id, 10) >= 7
|
||||
# Dual Blade skills
|
||||
job_id >= 430 and job_id <= 434 -> rem(job_id, 10) == 4 or skill.master_level > 0
|
||||
# Standard 4th job detection
|
||||
rem(job_id, 10) == 2 and skill.id < 90_000_000 and not is_beginner_skill?(skill) -> true
|
||||
true -> false
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if this is a beginner skill.
|
||||
"""
|
||||
@spec is_beginner_skill?(t()) :: boolean()
|
||||
def is_beginner_skill?(skill) do
|
||||
job_id = div(skill.id, 10000)
|
||||
job_id in [0, 1000, 2000, 2001, 2002, 3000, 3001, 1]
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if skill has required skills that must be learned first.
|
||||
"""
|
||||
@spec has_required_skill?(t()) :: boolean()
|
||||
def has_required_skill?(skill) do
|
||||
length(skill.required_skills) > 0
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the default skill expiration time for time-limited skills.
|
||||
Returns -1 for permanent skills, or 30 days from now for time-limited.
|
||||
"""
|
||||
@spec get_default_expiry(t()) :: integer()
|
||||
def get_default_expiry(skill) do
|
||||
if skill.time_limited do
|
||||
# 30 days in milliseconds
|
||||
System.system_time(:millisecond) + 30 * 24 * 60 * 60 * 1000
|
||||
else
|
||||
-1
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if this is a special skill (GM, admin, etc).
|
||||
"""
|
||||
@spec is_special_skill?(t()) :: boolean()
|
||||
def is_special_skill?(skill) do
|
||||
job_id = div(skill.id, 10000)
|
||||
job_id in [900, 800, 9000, 9200, 9201, 9202, 9203, 9204]
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a random animation from the skill's animation list.
|
||||
"""
|
||||
@spec get_animation(t()) :: integer() | nil
|
||||
def get_animation(skill) do
|
||||
if skill.animation && length(skill.animation) > 0 do
|
||||
{_, delay} = Enum.random(skill.animation)
|
||||
delay
|
||||
else
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
# Job type checks
|
||||
defp is_evan_job?(job_id), do: div(job_id, 100) == 22 or job_id == 2001
|
||||
defp is_adventurer_job?(job_id), do: div(job_id, 1000) == 0 and job_id not in [1]
|
||||
defp is_cygnus_job?(job_id), do: div(job_id, 1000) == 1
|
||||
defp is_aran_job?(job_id), do: div(job_id, 100) == 21 or job_id == 2000
|
||||
defp is_resistance_job?(job_id), do: div(job_id, 1000) == 3
|
||||
defp is_cannon_job?(job_id), do: div(job_id, 100) == 53 or job_id == 1
|
||||
defp is_demon_job?(job_id), do: div(job_id, 100) == 31 or job_id == 3001
|
||||
defp is_mercedes_job?(job_id), do: div(job_id, 100) == 23 or job_id == 2002
|
||||
end
|
||||
675
lib/odinsea/game/skill_factory.ex
Normal file
675
lib/odinsea/game/skill_factory.ex
Normal file
@@ -0,0 +1,675 @@
|
||||
defmodule Odinsea.Game.SkillFactory do
|
||||
@moduledoc """
|
||||
Skill Factory - loads and caches skill data.
|
||||
|
||||
Ported from Java: client/SkillFactory.java
|
||||
|
||||
This module loads skill metadata from cached JSON files.
|
||||
The JSON files should be exported from the Java server's WZ data providers.
|
||||
|
||||
Skill data is cached in ETS for fast lookups.
|
||||
"""
|
||||
|
||||
use GenServer
|
||||
require Logger
|
||||
|
||||
alias Odinsea.Game.{Skill, StatEffect}
|
||||
|
||||
# ETS table names
|
||||
@skill_cache :odinsea_skill_cache
|
||||
@skill_names :odinsea_skill_names
|
||||
@skills_by_job :odinsea_skills_by_job
|
||||
@summon_skills :odinsea_summon_skills
|
||||
|
||||
# Data file paths (relative to priv directory)
|
||||
@skill_data_file "data/skills.json"
|
||||
@skill_strings_file "data/skill_strings.json"
|
||||
|
||||
defmodule SummonSkillEntry do
|
||||
@moduledoc "Summon skill attack data"
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
skill_id: integer(),
|
||||
type: integer(),
|
||||
mob_count: integer(),
|
||||
attack_count: integer(),
|
||||
lt: {integer(), integer()},
|
||||
rb: {integer(), integer()},
|
||||
delay: integer()
|
||||
}
|
||||
|
||||
defstruct [
|
||||
:skill_id,
|
||||
:type,
|
||||
:mob_count,
|
||||
:attack_count,
|
||||
:lt,
|
||||
:rb,
|
||||
:delay
|
||||
]
|
||||
end
|
||||
|
||||
## Public API
|
||||
|
||||
@doc "Starts the SkillFactory GenServer"
|
||||
def start_link(opts \\ []) do
|
||||
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a skill by ID.
|
||||
Returns nil if not found.
|
||||
"""
|
||||
@spec get_skill(integer()) :: Skill.t() | nil
|
||||
def get_skill(skill_id) do
|
||||
case :ets.lookup(@skill_cache, skill_id) do
|
||||
[{^skill_id, skill}] -> skill
|
||||
[] -> nil
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets skill name by ID.
|
||||
"""
|
||||
@spec get_skill_name(integer()) :: String.t()
|
||||
def get_skill_name(skill_id) do
|
||||
case :ets.lookup(@skill_names, skill_id) do
|
||||
[{^skill_id, name}] -> name
|
||||
[] -> "UNKNOWN"
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets all skills for a specific job.
|
||||
"""
|
||||
@spec get_skills_by_job(integer()) :: [integer()]
|
||||
def get_skills_by_job(job_id) do
|
||||
case :ets.lookup(@skills_by_job, job_id) do
|
||||
[{^job_id, skills}] -> skills
|
||||
[] -> []
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets summon skill entry for a skill ID.
|
||||
"""
|
||||
@spec get_summon_data(integer()) :: SummonSkillEntry.t() | nil
|
||||
def get_summon_data(skill_id) do
|
||||
case :ets.lookup(@summon_skills, skill_id) do
|
||||
[{^skill_id, entry}] -> entry
|
||||
[] -> nil
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if a skill exists.
|
||||
"""
|
||||
@spec skill_exists?(integer()) :: boolean()
|
||||
def skill_exists?(skill_id) do
|
||||
:ets.member(@skill_cache, skill_id)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets all loaded skill IDs.
|
||||
"""
|
||||
@spec get_all_skill_ids() :: [integer()]
|
||||
def get_all_skill_ids do
|
||||
:ets.select(@skill_cache, [{{:"$1", :_}, [], [:"$1"]}])
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets skill effect for a specific level.
|
||||
Convenience function that combines get_skill and Skill.get_effect.
|
||||
"""
|
||||
@spec get_effect(integer(), integer()) :: StatEffect.t() | nil
|
||||
def get_effect(skill_id, level) do
|
||||
case get_skill(skill_id) do
|
||||
nil -> nil
|
||||
skill -> Skill.get_effect(skill, level)
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if a skill is a beginner skill.
|
||||
"""
|
||||
@spec is_beginner_skill?(integer()) :: boolean()
|
||||
def is_beginner_skill?(skill_id) do
|
||||
job_id = div(skill_id, 10000)
|
||||
job_id in [0, 1000, 2000, 2001, 2002, 3000, 3001, 1]
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the job ID for a skill.
|
||||
"""
|
||||
@spec get_skill_job(integer()) :: integer()
|
||||
def get_skill_job(skill_id) do
|
||||
div(skill_id, 10000)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Reloads skill 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(@skill_cache, [:set, :public, :named_table, read_concurrency: true])
|
||||
:ets.new(@skill_names, [:set, :public, :named_table, read_concurrency: true])
|
||||
:ets.new(@skills_by_job, [:set, :public, :named_table, read_concurrency: true])
|
||||
:ets.new(@summon_skills, [:set, :public, :named_table, read_concurrency: true])
|
||||
|
||||
# Load data
|
||||
load_skill_data()
|
||||
|
||||
{:ok, %{}}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call(:reload, _from, state) do
|
||||
Logger.info("Reloading skill data...")
|
||||
load_skill_data()
|
||||
{:reply, :ok, state}
|
||||
end
|
||||
|
||||
## Private Functions
|
||||
|
||||
defp load_skill_data do
|
||||
priv_dir = :code.priv_dir(:odinsea) |> to_string()
|
||||
|
||||
# Try to load from JSON files
|
||||
load_skill_strings(Path.join(priv_dir, @skill_strings_file))
|
||||
load_skills(Path.join(priv_dir, @skill_data_file))
|
||||
|
||||
skill_count = :ets.info(@skill_cache, :size)
|
||||
Logger.info("Loaded #{skill_count} skills")
|
||||
end
|
||||
|
||||
defp load_skill_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
|
||||
{skill_id, ""} -> :ets.insert(@skill_names, {skill_id, name})
|
||||
_ -> :ok
|
||||
end
|
||||
end)
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warn("Failed to parse skill strings JSON: #{inspect(reason)}")
|
||||
create_fallback_strings()
|
||||
end
|
||||
|
||||
{:error, :enoent} ->
|
||||
Logger.warn("Skill strings file not found: #{file_path}, using fallback data")
|
||||
create_fallback_strings()
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("Failed to read skill strings: #{inspect(reason)}")
|
||||
create_fallback_strings()
|
||||
end
|
||||
end
|
||||
|
||||
defp load_skills(file_path) do
|
||||
case File.read(file_path) do
|
||||
{:ok, content} ->
|
||||
case Jason.decode(content, keys: :atoms) do
|
||||
{:ok, skills} when is_list(skills) ->
|
||||
Enum.each(skills, fn skill_data ->
|
||||
skill = build_skill(skill_data)
|
||||
:ets.insert(@skill_cache, {skill.id, skill})
|
||||
|
||||
# Index by job
|
||||
job_id = div(skill.id, 10000)
|
||||
|
||||
existing =
|
||||
case :ets.lookup(@skills_by_job, job_id) do
|
||||
[{^job_id, list}] -> list
|
||||
[] -> []
|
||||
end
|
||||
|
||||
:ets.insert(@skills_by_job, {job_id, [skill.id | existing]})
|
||||
|
||||
# Check for summon data
|
||||
if skill_data[:summon] do
|
||||
entry = build_summon_entry(skill.id, skill_data[:summon])
|
||||
:ets.insert(@summon_skills, {skill.id, entry})
|
||||
end
|
||||
end)
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warn("Failed to parse skills JSON: #{inspect(reason)}")
|
||||
create_fallback_skills()
|
||||
end
|
||||
|
||||
{:error, :enoent} ->
|
||||
Logger.warn("Skills file not found: #{file_path}, using fallback data")
|
||||
create_fallback_skills()
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("Failed to read skills: #{inspect(reason)}")
|
||||
create_fallback_skills()
|
||||
end
|
||||
end
|
||||
|
||||
defp build_skill(data) do
|
||||
effects =
|
||||
(data[:effects] || [])
|
||||
|> Enum.map(&build_stat_effect/1)
|
||||
|
||||
pvp_effects =
|
||||
if data[:pvp_effects] do
|
||||
Enum.map(data[:pvp_effects], &build_stat_effect/1)
|
||||
else
|
||||
nil
|
||||
end
|
||||
|
||||
%Skill{
|
||||
id: data[:id] || data[:skill_id] || 0,
|
||||
name: data[:name] || "",
|
||||
element: parse_element(data[:element]),
|
||||
max_level: data[:max_level] || 0,
|
||||
true_max: data[:true_max] || data[:max_level] || 0,
|
||||
master_level: data[:master_level] || 0,
|
||||
effects: effects,
|
||||
pvp_effects: pvp_effects,
|
||||
required_skills: data[:required_skills] || [],
|
||||
skill_type: data[:skill_type] || 0,
|
||||
animation: data[:animation],
|
||||
animation_time: data[:animation_time] || 0,
|
||||
delay: data[:delay] || 0,
|
||||
invisible: data[:invisible] || false,
|
||||
time_limited: data[:time_limited] || false,
|
||||
combat_orders: data[:combat_orders] || false,
|
||||
charge_skill: data[:charge_skill] || false,
|
||||
magic: data[:magic] || false,
|
||||
caster_move: data[:caster_move] || false,
|
||||
push_target: data[:push_target] || false,
|
||||
pull_target: data[:pull_target] || false,
|
||||
not_removed: data[:not_removed] || false,
|
||||
pvp_disabled: data[:pvp_disabled] || false,
|
||||
event_taming_mob: data[:event_taming_mob] || 0
|
||||
}
|
||||
end
|
||||
|
||||
defp build_stat_effect(data) do
|
||||
%StatEffect{
|
||||
source_id: data[:source_id] || 0,
|
||||
level: data[:level] || 1,
|
||||
is_skill: data[:is_skill] || true,
|
||||
duration: data[:duration] || -1,
|
||||
over_time: data[:over_time] || false,
|
||||
hp: data[:hp] || 0,
|
||||
mp: data[:mp] || 0,
|
||||
hp_r: data[:hp_r] || 0.0,
|
||||
mp_r: data[:mp_r] || 0.0,
|
||||
mhp_r: data[:mhp_r] || 0,
|
||||
mmp_r: data[:mmp_r] || 0,
|
||||
watk: data[:watk] || data[:pad] || 0,
|
||||
wdef: data[:wdef] || data[:pdd] || 0,
|
||||
matk: data[:matk] || data[:mad] || 0,
|
||||
mdef: data[:mdef] || data[:mdd] || 0,
|
||||
acc: data[:acc] || 0,
|
||||
avoid: data[:avoid] || data[:eva] || 0,
|
||||
hands: data[:hands] || 0,
|
||||
speed: data[:speed] || 0,
|
||||
jump: data[:jump] || 0,
|
||||
mastery: data[:mastery] || 0,
|
||||
damage: data[:damage] || 100,
|
||||
pdd_r: data[:pdd_r] || 0,
|
||||
mdd_r: data[:mdd_r] || 0,
|
||||
dam_r: data[:dam_r] || 0,
|
||||
bd_r: data[:bd_r] || 0,
|
||||
ignore_mob: data[:ignore_mob] || 0,
|
||||
critical_damage_min: data[:critical_damage_min] || 0,
|
||||
critical_damage_max: data[:critical_damage_max] || 0,
|
||||
asr_r: data[:asr_r] || 0,
|
||||
er: data[:er] || 0,
|
||||
prop: data[:prop] || 100,
|
||||
mob_count: data[:mob_count] || 1,
|
||||
attack_count: data[:attack_count] || 1,
|
||||
bullet_count: data[:bullet_count] || 1,
|
||||
cooldown: data[:cooldown] || data[:cooltime] || 0,
|
||||
interval: data[:interval] || 0,
|
||||
mp_con: data[:mp_con] || 0,
|
||||
hp_con: data[:hp_con] || 0,
|
||||
force_con: data[:force_con] || 0,
|
||||
mp_con_reduce: data[:mp_con_reduce] || 0,
|
||||
move_to: data[:move_to] || -1,
|
||||
morph_id: data[:morph] || data[:morph_id] || 0,
|
||||
summon_movement_type: parse_summon_movement(data[:summon_movement]),
|
||||
dot: data[:dot] || 0,
|
||||
dot_time: data[:dot_time] || 0,
|
||||
thaw: data[:thaw] || 0,
|
||||
self_destruction: data[:self_destruction] || 0,
|
||||
pvp_damage: data[:pvp_damage] || 0,
|
||||
inc_pvp_damage: data[:inc_pvp_damage] || 0,
|
||||
indie_pad: data[:indie_pad] || 0,
|
||||
indie_mad: data[:indie_mad] || 0,
|
||||
indie_mhp: data[:indie_mhp] || 0,
|
||||
indie_mmp: data[:indie_mmp] || 0,
|
||||
indie_speed: data[:indie_speed] || 0,
|
||||
indie_jump: data[:indie_jump] || 0,
|
||||
indie_acc: data[:indie_acc] || 0,
|
||||
indie_eva: data[:indie_eva] || 0,
|
||||
indie_pdd: data[:indie_pdd] || 0,
|
||||
indie_mdd: data[:indie_mdd] || 0,
|
||||
indie_all_stat: data[:indie_all_stat] || 0,
|
||||
str: data[:str] || 0,
|
||||
dex: data[:dex] || 0,
|
||||
int: data[:int] || 0,
|
||||
luk: data[:luk] || 0,
|
||||
str_x: data[:str_x] || 0,
|
||||
dex_x: data[:dex_x] || 0,
|
||||
int_x: data[:int_x] || 0,
|
||||
luk_x: data[:luk_x] || 0,
|
||||
x: data[:x] || 0,
|
||||
y: data[:y] || 0,
|
||||
z: data[:z] || 0,
|
||||
stat_ups: data[:stat_ups] || %{},
|
||||
monster_status: data[:monster_status] || %{}
|
||||
}
|
||||
end
|
||||
|
||||
defp build_summon_entry(skill_id, summon_data) do
|
||||
%SummonSkillEntry{
|
||||
skill_id: skill_id,
|
||||
type: summon_data[:type] || 0,
|
||||
mob_count: summon_data[:mob_count] || 1,
|
||||
attack_count: summon_data[:attack_count] || 1,
|
||||
lt: parse_point(summon_data[:lt]) || {-100, -100},
|
||||
rb: parse_point(summon_data[:rb]) || {100, 100},
|
||||
delay: summon_data[:delay] || 0
|
||||
}
|
||||
end
|
||||
|
||||
defp parse_element(nil), do: :neutral
|
||||
defp parse_element("f"), do: :fire
|
||||
defp parse_element("i"), do: :ice
|
||||
defp parse_element("l"), do: :lightning
|
||||
defp parse_element("p"), do: :poison
|
||||
defp parse_element("h"), do: :holy
|
||||
defp parse_element("d"), do: :dark
|
||||
defp parse_element("s"), do: :physical
|
||||
defp parse_element(atom) when is_atom(atom), do: atom
|
||||
defp parse_element(_), do: :neutral
|
||||
|
||||
defp parse_summon_movement(nil), do: nil
|
||||
defp parse_summon_movement("follow"), do: :follow
|
||||
defp parse_summon_movement("stationary"), do: :stationary
|
||||
defp parse_summon_movement("circle_follow"), do: :circle_follow
|
||||
defp parse_summon_movement(atom) when is_atom(atom), do: atom
|
||||
defp parse_summon_movement(_), do: nil
|
||||
|
||||
defp parse_point(nil), do: nil
|
||||
defp parse_point({x, y}), do: {x, y}
|
||||
defp parse_point([x, y]), do: {x, y}
|
||||
defp parse_point(%{x: x, y: y}), do: {x, y}
|
||||
defp parse_point(_), do: nil
|
||||
|
||||
# Fallback data for basic testing without WZ exports
|
||||
defp create_fallback_strings do
|
||||
fallback_names = %{
|
||||
# Beginner skills
|
||||
1_000 => "Three Snails",
|
||||
1_001 => "Recovery",
|
||||
1_002 => "Nimble Feet",
|
||||
1_003 => "Monster Rider",
|
||||
1_004 => "Echo of Hero",
|
||||
|
||||
# Warrior 1st job
|
||||
100_000 => "Power Strike",
|
||||
100_001 => "Slash Blast",
|
||||
100_002 => "Iron Body",
|
||||
100_003 => "Iron Body",
|
||||
100_004 => "Power Strike",
|
||||
100_005 => "Slash Blast",
|
||||
100_006 => "Iron Body",
|
||||
100_007 => "Power Strike",
|
||||
100_008 => "Slash Blast",
|
||||
100_009 => "Iron Body",
|
||||
100_010 => "Power Strike",
|
||||
100_100 => "Power Strike",
|
||||
100_101 => "Slash Blast",
|
||||
100_102 => "Iron Body",
|
||||
|
||||
# Magician 1st job
|
||||
200_000 => "Magic Claw",
|
||||
200_001 => "Teleport",
|
||||
200_002 => "Magic Guard",
|
||||
200_003 => "Magic Armor",
|
||||
200_004 => "Energy Bolt",
|
||||
200_005 => "Magic Claw",
|
||||
200_006 => "Teleport",
|
||||
200_007 => "Magic Guard",
|
||||
200_008 => "Magic Armor",
|
||||
200_009 => "Energy Bolt",
|
||||
200_100 => "Magic Claw",
|
||||
200_101 => "Teleport",
|
||||
200_102 => "Magic Guard",
|
||||
200_103 => "Magic Armor",
|
||||
200_104 => "Energy Bolt",
|
||||
|
||||
# Bowman 1st job
|
||||
300_000 => "Arrow Blow",
|
||||
300_001 => "Double Shot",
|
||||
300_002 => "Critical Shot",
|
||||
300_003 => "The Eye of Amazon",
|
||||
300_004 => "Focus",
|
||||
300_100 => "Arrow Blow",
|
||||
300_101 => "Double Shot",
|
||||
300_102 => "Critical Shot",
|
||||
300_103 => "The Eye of Amazon",
|
||||
300_104 => "Focus",
|
||||
|
||||
# Thief 1st job
|
||||
400_000 => "Lucky Seven",
|
||||
400_001 => "Double Stab",
|
||||
400_002 => "Disorder",
|
||||
400_003 => "Dark Sight",
|
||||
400_004 => "Lucky Seven",
|
||||
400_005 => "Double Stab",
|
||||
400_100 => "Lucky Seven",
|
||||
400_101 => "Double Stab",
|
||||
400_102 => "Disorder",
|
||||
400_103 => "Dark Sight",
|
||||
400_104 => "Lucky Seven",
|
||||
400_105 => "Double Stab",
|
||||
|
||||
# Pirate 1st job
|
||||
500_000 => "Somersault Kick",
|
||||
500_001 => "Double Fire",
|
||||
500_002 => "Dash",
|
||||
500_003 => "Shadow Heart",
|
||||
500_004 => "Somersault Kick",
|
||||
500_005 => "Double Fire",
|
||||
500_100 => "Somersault Kick",
|
||||
500_101 => "Double Fire",
|
||||
500_102 => "Dash",
|
||||
500_103 => "Shadow Heart",
|
||||
500_104 => "Somersault Kick",
|
||||
500_105 => "Double Fire",
|
||||
|
||||
# GM skills
|
||||
9_001_000 => "Haste",
|
||||
9_001_001 => "Dragon Roar",
|
||||
9_001_002 => "Holy Symbol",
|
||||
9_001_003 => "Heal",
|
||||
9_001_004 => "Hide",
|
||||
9_001_005 => "Resurrection",
|
||||
9_001_006 => "Hyper Body",
|
||||
9_001_007 => "Holy Shield",
|
||||
9_001_008 => "Holy Shield",
|
||||
|
||||
# 4th job common
|
||||
1_122_004 => "Hero's Will",
|
||||
1_222_004 => "Hero's Will",
|
||||
1_322_004 => "Hero's Will",
|
||||
2_122_004 => "Hero's Will",
|
||||
2_222_004 => "Hero's Will",
|
||||
2_322_004 => "Hero's Will",
|
||||
3_122_004 => "Hero's Will",
|
||||
4_122_004 => "Hero's Will",
|
||||
4_222_004 => "Hero's Will",
|
||||
5_122_004 => "Hero's Will",
|
||||
5_222_004 => "Hero's Will",
|
||||
|
||||
# Maple Warrior (all 4th jobs)
|
||||
1_121_000 => "Maple Warrior",
|
||||
1_221_000 => "Maple Warrior",
|
||||
1_321_000 => "Maple Warrior",
|
||||
2_121_000 => "Maple Warrior",
|
||||
2_221_000 => "Maple Warrior",
|
||||
2_321_000 => "Maple Warrior",
|
||||
3_121_000 => "Maple Warrior",
|
||||
3_221_000 => "Maple Warrior",
|
||||
4_121_000 => "Maple Warrior",
|
||||
4_221_000 => "Maple Warrior",
|
||||
5_121_000 => "Maple Warrior",
|
||||
5_221_000 => "Maple Warrior"
|
||||
}
|
||||
|
||||
Enum.each(fallback_names, fn {skill_id, name} ->
|
||||
:ets.insert(@skill_names, {skill_id, name})
|
||||
end)
|
||||
end
|
||||
|
||||
defp create_fallback_skills do
|
||||
# Create some basic beginner skills as fallback
|
||||
fallback_skills = [
|
||||
%{
|
||||
id: 1_000,
|
||||
name: "Three Snails",
|
||||
element: :physical,
|
||||
max_level: 3,
|
||||
true_max: 3,
|
||||
effects: [
|
||||
%{level: 1, damage: 150, mp_con: 10, mob_count: 1, x: 15},
|
||||
%{level: 2, damage: 200, mp_con: 15, mob_count: 1, x: 30},
|
||||
%{level: 3, damage: 250, mp_con: 20, mob_count: 1, x: 45}
|
||||
]
|
||||
},
|
||||
%{
|
||||
id: 1_001,
|
||||
name: "Recovery",
|
||||
element: :neutral,
|
||||
max_level: 3,
|
||||
true_max: 3,
|
||||
effects: [
|
||||
%{level: 1, duration: 30000, hp: 10, interval: 2000, x: 10},
|
||||
%{level: 2, duration: 30000, hp: 20, interval: 1900, x: 20},
|
||||
%{level: 3, duration: 30000, hp: 30, interval: 1800, x: 30}
|
||||
]
|
||||
},
|
||||
%{
|
||||
id: 1_002,
|
||||
name: "Nimble Feet",
|
||||
element: :neutral,
|
||||
max_level: 3,
|
||||
true_max: 3,
|
||||
effects: [
|
||||
%{level: 1, duration: 4000, speed: 10, x: 10},
|
||||
%{level: 2, duration: 8000, speed: 15, x: 15},
|
||||
%{level: 3, duration: 12000, speed: 20, x: 20}
|
||||
]
|
||||
},
|
||||
%{
|
||||
id: 1_004,
|
||||
name: "Echo of Hero",
|
||||
element: :neutral,
|
||||
max_level: 1,
|
||||
true_max: 1,
|
||||
effects: [
|
||||
%{level: 1, duration: 1200000, watk: 4, wdef: 4, matk: 4, mdef: 4, x: 4}
|
||||
]
|
||||
},
|
||||
%{
|
||||
id: 100_000,
|
||||
name: "Power Strike",
|
||||
element: :physical,
|
||||
max_level: 20,
|
||||
true_max: 20,
|
||||
skill_type: 1,
|
||||
effects: [
|
||||
%{level: 1, damage: 145, mp_con: 8, mob_count: 1, attack_count: 1},
|
||||
%{level: 10, damage: 190, mp_con: 16, mob_count: 1, attack_count: 1},
|
||||
%{level: 20, damage: 245, mp_con: 24, mob_count: 1, attack_count: 1}
|
||||
]
|
||||
},
|
||||
%{
|
||||
id: 100_001,
|
||||
name: "Slash Blast",
|
||||
element: :physical,
|
||||
max_level: 20,
|
||||
true_max: 20,
|
||||
skill_type: 1,
|
||||
effects: [
|
||||
%{level: 1, damage: 85, mp_con: 8, mob_count: 3, attack_count: 1},
|
||||
%{level: 10, damage: 115, mp_con: 16, mob_count: 4, attack_count: 1},
|
||||
%{level: 20, damage: 150, mp_con: 24, mob_count: 6, attack_count: 1}
|
||||
]
|
||||
},
|
||||
%{
|
||||
id: 200_000,
|
||||
name: "Magic Claw",
|
||||
element: :neutral,
|
||||
max_level: 20,
|
||||
true_max: 20,
|
||||
magic: true,
|
||||
skill_type: 1,
|
||||
effects: [
|
||||
%{level: 1, damage: 132, mp_con: 12, mob_count: 2, attack_count: 1, x: 22},
|
||||
%{level: 10, damage: 156, mp_con: 24, mob_count: 2, attack_count: 1, x: 26},
|
||||
%{level: 20, damage: 182, mp_con: 36, mob_count: 2, attack_count: 1, x: 30}
|
||||
]
|
||||
},
|
||||
%{
|
||||
id: 200_001,
|
||||
name: "Teleport",
|
||||
element: :neutral,
|
||||
max_level: 20,
|
||||
true_max: 20,
|
||||
skill_type: 2,
|
||||
effects: [
|
||||
%{level: 1, mp_con: 40, x: 70},
|
||||
%{level: 10, mp_con: 35, x: 115},
|
||||
%{level: 20, mp_con: 30, x: 160}
|
||||
]
|
||||
},
|
||||
%{
|
||||
id: 200_002,
|
||||
name: "Magic Guard",
|
||||
element: :neutral,
|
||||
max_level: 20,
|
||||
true_max: 20,
|
||||
skill_type: 2,
|
||||
effects: [
|
||||
%{level: 1, x: 15},
|
||||
%{level: 10, x: 42},
|
||||
%{level: 20, x: 70}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
Enum.each(fallback_skills, fn skill_data ->
|
||||
skill = build_skill(skill_data)
|
||||
:ets.insert(@skill_cache, {skill.id, skill})
|
||||
|
||||
job_id = div(skill.id, 10000)
|
||||
|
||||
existing =
|
||||
case :ets.lookup(@skills_by_job, job_id) do
|
||||
[{^job_id, list}] -> list
|
||||
[] -> []
|
||||
end
|
||||
|
||||
:ets.insert(@skills_by_job, {job_id, [skill.id | existing]})
|
||||
end)
|
||||
end
|
||||
end
|
||||
741
lib/odinsea/game/stat_effect.ex
Normal file
741
lib/odinsea/game/stat_effect.ex
Normal file
@@ -0,0 +1,741 @@
|
||||
defmodule Odinsea.Game.StatEffect do
|
||||
@moduledoc """
|
||||
StatEffect struct for skill and item effects.
|
||||
|
||||
Ported from Java: server/MapleStatEffect.java
|
||||
|
||||
StatEffects define what happens when a skill or item is used:
|
||||
- Stat changes (WATK, WDEF, MATK, MDEF, etc.)
|
||||
- HP/MP changes
|
||||
- Buffs and debuffs
|
||||
- Monster status effects
|
||||
- Cooldowns and durations
|
||||
"""
|
||||
|
||||
alias Odinsea.Game.MonsterStatus
|
||||
|
||||
defstruct [
|
||||
# Basic info
|
||||
:source_id,
|
||||
:level,
|
||||
:is_skill,
|
||||
:duration,
|
||||
:over_time,
|
||||
|
||||
# HP/MP
|
||||
:hp,
|
||||
:mp,
|
||||
:hp_r,
|
||||
:mp_r,
|
||||
:mhp_r,
|
||||
:mmp_r,
|
||||
|
||||
# Combat stats
|
||||
:watk,
|
||||
:wdef,
|
||||
:matk,
|
||||
:mdef,
|
||||
:acc,
|
||||
:avoid,
|
||||
:hands,
|
||||
:speed,
|
||||
:jump,
|
||||
:mastery,
|
||||
|
||||
# Damage modifiers
|
||||
:damage,
|
||||
:pdd_r,
|
||||
:mdd_r,
|
||||
:dam_r,
|
||||
:bd_r,
|
||||
:ignore_mob,
|
||||
:critical_damage_min,
|
||||
:critical_damage_max,
|
||||
:asr_r,
|
||||
:er,
|
||||
|
||||
# Skill-specific
|
||||
:prop,
|
||||
:mob_count,
|
||||
:attack_count,
|
||||
:bullet_count,
|
||||
:cooldown,
|
||||
:interval,
|
||||
|
||||
# MP/HP consumption
|
||||
:mp_con,
|
||||
:hp_con,
|
||||
:force_con,
|
||||
:mp_con_reduce,
|
||||
|
||||
# Movement
|
||||
:move_to,
|
||||
|
||||
# Morph
|
||||
:morph_id,
|
||||
|
||||
# Summon
|
||||
:summon_movement_type,
|
||||
|
||||
# DoT (Damage over Time)
|
||||
:dot,
|
||||
:dot_time,
|
||||
|
||||
# Special effects
|
||||
:thaw,
|
||||
:self_destruction,
|
||||
:pvp_damage,
|
||||
:inc_pvp_damage,
|
||||
|
||||
# Independent stats (angel buffs)
|
||||
:indie_pad,
|
||||
:indie_mad,
|
||||
:indie_mhp,
|
||||
:indie_mmp,
|
||||
:indie_speed,
|
||||
:indie_jump,
|
||||
:indie_acc,
|
||||
:indie_eva,
|
||||
:indie_pdd,
|
||||
:indie_mdd,
|
||||
:indie_all_stat,
|
||||
|
||||
# Base stats
|
||||
:str,
|
||||
:dex,
|
||||
:int,
|
||||
:luk,
|
||||
:str_x,
|
||||
:dex_x,
|
||||
:int_x,
|
||||
:luk_x,
|
||||
|
||||
# Enhanced stats
|
||||
:ehp,
|
||||
:emp,
|
||||
:ewatk,
|
||||
:ewdef,
|
||||
:emdef,
|
||||
|
||||
# Misc
|
||||
:pad_x,
|
||||
:mad_x,
|
||||
:meso_r,
|
||||
:exp_r,
|
||||
|
||||
# Item consumption
|
||||
:item_con,
|
||||
:item_con_no,
|
||||
:bullet_consume,
|
||||
:money_con,
|
||||
|
||||
# Position/Range
|
||||
:lt,
|
||||
:rb,
|
||||
:range,
|
||||
|
||||
# Buff stats (map of CharacterTemporaryStat => value)
|
||||
:stat_ups,
|
||||
|
||||
# Monster status effects
|
||||
:monster_status,
|
||||
|
||||
# Cure debuffs
|
||||
:cure_debuffs,
|
||||
|
||||
# Other
|
||||
:expinc,
|
||||
:exp_buff,
|
||||
:itemup,
|
||||
:mesoup,
|
||||
:cashup,
|
||||
:berserk,
|
||||
:berserk2,
|
||||
:booster,
|
||||
:illusion,
|
||||
:life_id,
|
||||
:inflation,
|
||||
:imhp,
|
||||
:immp,
|
||||
:use_level,
|
||||
:char_color,
|
||||
:recipe,
|
||||
:recipe_use_count,
|
||||
:recipe_valid_day,
|
||||
:req_skill_level,
|
||||
:slot_count,
|
||||
:preventslip,
|
||||
:immortal,
|
||||
:type,
|
||||
:bs,
|
||||
:cr,
|
||||
:t,
|
||||
:u,
|
||||
:v,
|
||||
:w,
|
||||
:x,
|
||||
:y,
|
||||
:z,
|
||||
:mob_skill,
|
||||
:mob_skill_level,
|
||||
:familiar_target,
|
||||
:fatigue_change,
|
||||
:available_maps,
|
||||
:reward_meso,
|
||||
:reward_items,
|
||||
:pets_can_consume,
|
||||
:familiars,
|
||||
:random_pickup,
|
||||
:traits,
|
||||
:party_buff
|
||||
]
|
||||
|
||||
@type point :: {integer(), integer()}
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
source_id: integer(),
|
||||
level: integer(),
|
||||
is_skill: boolean(),
|
||||
duration: integer(),
|
||||
over_time: boolean(),
|
||||
hp: integer(),
|
||||
mp: integer(),
|
||||
hp_r: float(),
|
||||
mp_r: float(),
|
||||
mhp_r: integer(),
|
||||
mmp_r: integer(),
|
||||
watk: integer(),
|
||||
wdef: integer(),
|
||||
matk: integer(),
|
||||
mdef: integer(),
|
||||
acc: integer(),
|
||||
avoid: integer(),
|
||||
hands: integer(),
|
||||
speed: integer(),
|
||||
jump: integer(),
|
||||
mastery: integer(),
|
||||
damage: integer(),
|
||||
pdd_r: integer(),
|
||||
mdd_r: integer(),
|
||||
dam_r: integer(),
|
||||
bd_r: integer(),
|
||||
ignore_mob: integer(),
|
||||
critical_damage_min: integer(),
|
||||
critical_damage_max: integer(),
|
||||
asr_r: integer(),
|
||||
er: integer(),
|
||||
prop: integer(),
|
||||
mob_count: integer(),
|
||||
attack_count: integer(),
|
||||
bullet_count: integer(),
|
||||
cooldown: integer(),
|
||||
interval: integer(),
|
||||
mp_con: integer(),
|
||||
hp_con: integer(),
|
||||
force_con: integer(),
|
||||
mp_con_reduce: integer(),
|
||||
move_to: integer(),
|
||||
morph_id: integer(),
|
||||
summon_movement_type: atom() | nil,
|
||||
dot: integer(),
|
||||
dot_time: integer(),
|
||||
thaw: integer(),
|
||||
self_destruction: integer(),
|
||||
pvp_damage: integer(),
|
||||
inc_pvp_damage: integer(),
|
||||
indie_pad: integer(),
|
||||
indie_mad: integer(),
|
||||
indie_mhp: integer(),
|
||||
indie_mmp: integer(),
|
||||
indie_speed: integer(),
|
||||
indie_jump: integer(),
|
||||
indie_acc: integer(),
|
||||
indie_eva: integer(),
|
||||
indie_pdd: integer(),
|
||||
indie_mdd: integer(),
|
||||
indie_all_stat: integer(),
|
||||
str: integer(),
|
||||
dex: integer(),
|
||||
int: integer(),
|
||||
luk: integer(),
|
||||
str_x: integer(),
|
||||
dex_x: integer(),
|
||||
int_x: integer(),
|
||||
luk_x: integer(),
|
||||
ehp: integer(),
|
||||
emp: integer(),
|
||||
ewatk: integer(),
|
||||
ewdef: integer(),
|
||||
emdef: integer(),
|
||||
pad_x: integer(),
|
||||
mad_x: integer(),
|
||||
meso_r: integer(),
|
||||
exp_r: integer(),
|
||||
item_con: integer(),
|
||||
item_con_no: integer(),
|
||||
bullet_consume: integer(),
|
||||
money_con: integer(),
|
||||
lt: point() | nil,
|
||||
rb: point() | nil,
|
||||
range: integer(),
|
||||
stat_ups: map(),
|
||||
monster_status: map(),
|
||||
cure_debuffs: [atom()],
|
||||
expinc: integer(),
|
||||
exp_buff: integer(),
|
||||
itemup: integer(),
|
||||
mesoup: integer(),
|
||||
cashup: integer(),
|
||||
berserk: integer(),
|
||||
berserk2: integer(),
|
||||
booster: integer(),
|
||||
illusion: integer(),
|
||||
life_id: integer(),
|
||||
inflation: integer(),
|
||||
imhp: integer(),
|
||||
immp: integer(),
|
||||
use_level: integer(),
|
||||
char_color: integer(),
|
||||
recipe: integer(),
|
||||
recipe_use_count: integer(),
|
||||
recipe_valid_day: integer(),
|
||||
req_skill_level: integer(),
|
||||
slot_count: integer(),
|
||||
preventslip: integer(),
|
||||
immortal: integer(),
|
||||
type: integer(),
|
||||
bs: integer(),
|
||||
cr: integer(),
|
||||
t: integer(),
|
||||
u: integer(),
|
||||
v: integer(),
|
||||
w: integer(),
|
||||
x: integer(),
|
||||
y: integer(),
|
||||
z: integer(),
|
||||
mob_skill: integer(),
|
||||
mob_skill_level: integer(),
|
||||
familiar_target: integer(),
|
||||
fatigue_change: integer(),
|
||||
available_maps: [{integer(), integer()}],
|
||||
reward_meso: integer(),
|
||||
reward_items: [{integer(), integer(), integer()}],
|
||||
pets_can_consume: [integer()],
|
||||
familiars: [integer()],
|
||||
random_pickup: [integer()],
|
||||
traits: map(),
|
||||
party_buff: boolean()
|
||||
}
|
||||
|
||||
@doc """
|
||||
Creates a new StatEffect with default values.
|
||||
"""
|
||||
@spec new(integer(), integer(), boolean()) :: t()
|
||||
def new(source_id, level, is_skill) do
|
||||
%__MODULE__{
|
||||
source_id: source_id,
|
||||
level: level,
|
||||
is_skill: is_skill,
|
||||
duration: -1,
|
||||
over_time: false,
|
||||
hp: 0,
|
||||
mp: 0,
|
||||
hp_r: 0.0,
|
||||
mp_r: 0.0,
|
||||
mhp_r: 0,
|
||||
mmp_r: 0,
|
||||
watk: 0,
|
||||
wdef: 0,
|
||||
matk: 0,
|
||||
mdef: 0,
|
||||
acc: 0,
|
||||
avoid: 0,
|
||||
hands: 0,
|
||||
speed: 0,
|
||||
jump: 0,
|
||||
mastery: 0,
|
||||
damage: 100,
|
||||
pdd_r: 0,
|
||||
mdd_r: 0,
|
||||
dam_r: 0,
|
||||
bd_r: 0,
|
||||
ignore_mob: 0,
|
||||
critical_damage_min: 0,
|
||||
critical_damage_max: 0,
|
||||
asr_r: 0,
|
||||
er: 0,
|
||||
prop: 100,
|
||||
mob_count: 1,
|
||||
attack_count: 1,
|
||||
bullet_count: 1,
|
||||
cooldown: 0,
|
||||
interval: 0,
|
||||
mp_con: 0,
|
||||
hp_con: 0,
|
||||
force_con: 0,
|
||||
mp_con_reduce: 0,
|
||||
move_to: -1,
|
||||
morph_id: 0,
|
||||
summon_movement_type: nil,
|
||||
dot: 0,
|
||||
dot_time: 0,
|
||||
thaw: 0,
|
||||
self_destruction: 0,
|
||||
pvp_damage: 0,
|
||||
inc_pvp_damage: 0,
|
||||
indie_pad: 0,
|
||||
indie_mad: 0,
|
||||
indie_mhp: 0,
|
||||
indie_mmp: 0,
|
||||
indie_speed: 0,
|
||||
indie_jump: 0,
|
||||
indie_acc: 0,
|
||||
indie_eva: 0,
|
||||
indie_pdd: 0,
|
||||
indie_mdd: 0,
|
||||
indie_all_stat: 0,
|
||||
str: 0,
|
||||
dex: 0,
|
||||
int: 0,
|
||||
luk: 0,
|
||||
str_x: 0,
|
||||
dex_x: 0,
|
||||
int_x: 0,
|
||||
luk_x: 0,
|
||||
ehp: 0,
|
||||
emp: 0,
|
||||
ewatk: 0,
|
||||
ewdef: 0,
|
||||
emdef: 0,
|
||||
pad_x: 0,
|
||||
mad_x: 0,
|
||||
meso_r: 0,
|
||||
exp_r: 0,
|
||||
item_con: 0,
|
||||
item_con_no: 0,
|
||||
bullet_consume: 0,
|
||||
money_con: 0,
|
||||
lt: nil,
|
||||
rb: nil,
|
||||
range: 0,
|
||||
stat_ups: %{},
|
||||
monster_status: %{},
|
||||
cure_debuffs: [],
|
||||
expinc: 0,
|
||||
exp_buff: 0,
|
||||
itemup: 0,
|
||||
mesoup: 0,
|
||||
cashup: 0,
|
||||
berserk: 0,
|
||||
berserk2: 0,
|
||||
booster: 0,
|
||||
illusion: 0,
|
||||
life_id: 0,
|
||||
inflation: 0,
|
||||
imhp: 0,
|
||||
immp: 0,
|
||||
use_level: 0,
|
||||
char_color: 0,
|
||||
recipe: 0,
|
||||
recipe_use_count: 0,
|
||||
recipe_valid_day: 0,
|
||||
req_skill_level: 0,
|
||||
slot_count: 0,
|
||||
preventslip: 0,
|
||||
immortal: 0,
|
||||
type: 0,
|
||||
bs: 0,
|
||||
cr: 0,
|
||||
t: 0,
|
||||
u: 0,
|
||||
v: 0,
|
||||
w: 0,
|
||||
x: 0,
|
||||
y: 0,
|
||||
z: 0,
|
||||
mob_skill: 0,
|
||||
mob_skill_level: 0,
|
||||
familiar_target: 0,
|
||||
fatigue_change: 0,
|
||||
available_maps: [],
|
||||
reward_meso: 0,
|
||||
reward_items: [],
|
||||
pets_can_consume: [],
|
||||
familiars: [],
|
||||
random_pickup: [],
|
||||
traits: %{},
|
||||
party_buff: true
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if this effect has a cooldown.
|
||||
"""
|
||||
@spec has_cooldown?(t()) :: boolean()
|
||||
def has_cooldown?(effect) do
|
||||
effect.cooldown > 0
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if this is a heal effect.
|
||||
"""
|
||||
@spec is_heal?(t()) :: boolean()
|
||||
def is_heal?(effect) do
|
||||
effect.source_id in [2_301_002, 9_101_002, 9_101_004]
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if this is a resurrection effect.
|
||||
"""
|
||||
@spec is_resurrection?(t()) :: boolean()
|
||||
def is_resurrection?(effect) do
|
||||
effect.source_id == 2_321_006
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if this is a dispel effect.
|
||||
"""
|
||||
@spec is_dispel?(t()) :: boolean()
|
||||
def is_dispel?(effect) do
|
||||
effect.source_id == 2_311_001
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if this is a hero's will effect.
|
||||
"""
|
||||
@spec is_hero_will?(t()) :: boolean()
|
||||
def is_hero_will?(effect) do
|
||||
effect.source_id in [1_121_004, 1_221_004, 1_321_004, 2_122_004, 2_222_004,
|
||||
2_322_004, 3_122_004, 4_122_004, 4_222_004, 5_122_004,
|
||||
5_222_004, 2_217_004, 4_341_000, 3_221_007, 3_321_007]
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if this is a time leap effect.
|
||||
"""
|
||||
@spec is_time_leap?(t()) :: boolean()
|
||||
def is_time_leap?(effect) do
|
||||
effect.source_id == 5_121_010
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if this is a mist effect.
|
||||
"""
|
||||
@spec is_mist?(t()) :: boolean()
|
||||
def is_mist?(effect) do
|
||||
effect.source_id in [2_111_003, 2_211_003, 1_211_005]
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if this is a magic door effect.
|
||||
"""
|
||||
@spec is_magic_door?(t()) :: boolean()
|
||||
def is_magic_door?(effect) do
|
||||
effect.source_id == 2_311_002
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if this is a poison effect.
|
||||
"""
|
||||
@spec is_poison?(t()) :: boolean()
|
||||
def is_poison?(effect) do
|
||||
effect.dot > 0 and effect.dot_time > 0
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if this is a morph effect.
|
||||
"""
|
||||
@spec is_morph?(t()) :: boolean()
|
||||
def is_morph?(effect) do
|
||||
effect.morph_id > 0
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if this is a final attack effect.
|
||||
"""
|
||||
@spec is_final_attack?(t()) :: boolean()
|
||||
def is_final_attack?(effect) do
|
||||
effect.source_id in [1_100_002, 1_200_002, 1_300_002, 3_100_001, 3_200_001,
|
||||
1_110_002, 1_310_002, 2_111_007, 2_221_007, 2_311_007,
|
||||
3_211_010, 3_310_009, 2_215_004, 2_218_004, 1_120_013,
|
||||
3_120_008, 2_310_006, 2_312_012]
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if this is an energy charge effect.
|
||||
"""
|
||||
@spec is_energy_charge?(t()) :: boolean()
|
||||
def is_energy_charge?(effect) do
|
||||
effect.source_id in [5_110_001, 1_510_004]
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if this effect makes the player invisible.
|
||||
"""
|
||||
@spec is_hide?(t()) :: boolean()
|
||||
def is_hide?(effect) do
|
||||
effect.source_id in [9_101_004, 9_001_004, 4_330_001]
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if this is a shadow partner effect.
|
||||
"""
|
||||
@spec is_shadow_partner?(t()) :: boolean()
|
||||
def is_shadow_partner?(effect) do
|
||||
effect.source_id in [4_111_002, 1_411_000, 4_331_002, 4_211_008]
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if this is a combo recharge effect.
|
||||
"""
|
||||
@spec is_combo_recharge?(t()) :: boolean()
|
||||
def is_combo_recharge?(effect) do
|
||||
effect.source_id == 2_111_009
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if this is a spirit claw effect.
|
||||
"""
|
||||
@spec is_spirit_claw?(t()) :: boolean()
|
||||
def is_spirit_claw?(effect) do
|
||||
effect.source_id == 4_121_006
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if this is a Mech door effect.
|
||||
"""
|
||||
@spec is_mech_door?(t()) :: boolean()
|
||||
def is_mech_door?(effect) do
|
||||
effect.source_id == 3_511_005
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if this is a mist eruption effect.
|
||||
"""
|
||||
@spec is_mist_eruption?(t()) :: boolean()
|
||||
def is_mist_eruption?(effect) do
|
||||
effect.source_id == 2_121_005
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if this effect affects monsters.
|
||||
"""
|
||||
@spec is_monster_buff?(t()) :: boolean()
|
||||
def is_monster_buff?(effect) do
|
||||
count = stat_size(effect.monster_status)
|
||||
count > 0
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if this is a party buff.
|
||||
"""
|
||||
@spec is_party_buff?(t()) :: boolean()
|
||||
def is_party_buff?(effect) do
|
||||
effect.party_buff
|
||||
end
|
||||
|
||||
@doc """
|
||||
Calculates the bounding box for this effect based on position.
|
||||
"""
|
||||
@spec calculate_bounding_box(t(), {integer(), integer()}, boolean()) ::
|
||||
{{integer(), integer()}, {integer(), integer()}} | nil
|
||||
def calculate_bounding_box(effect, {x, y}, facing_left) do
|
||||
case {effect.lt, effect.rb} do
|
||||
{nil, nil} ->
|
||||
# Default bounding box
|
||||
width = 200 + effect.range
|
||||
height = 100 + effect.range
|
||||
|
||||
if facing_left do
|
||||
{{x - width, y - div(height, 2)}, {x, y + div(height, 2)}}
|
||||
else
|
||||
{{x, y - div(height, 2)}, {x + width, y + div(height, 2)}}
|
||||
end
|
||||
|
||||
{{lt_x, lt_y}, {rb_x, rb_y}} ->
|
||||
if facing_left do
|
||||
{{x + lt_x - effect.range, y + lt_y}, {x + rb_x, y + rb_y}}
|
||||
else
|
||||
{{x - rb_x + effect.range, y + lt_y}, {x - lt_x, y + rb_y}}
|
||||
end
|
||||
|
||||
_ ->
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Makes a chance result check based on the effect's prop value.
|
||||
"""
|
||||
@spec make_chance_result?(t()) :: boolean()
|
||||
def make_chance_result?(effect) do
|
||||
effect.prop >= 100 or :rand.uniform(100) < effect.prop
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the summon movement type if this effect summons something.
|
||||
"""
|
||||
@spec get_summon_movement_type(t()) :: atom() | nil
|
||||
def get_summon_movement_type(effect) do
|
||||
effect.summon_movement_type
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the total stat change for a specific stat.
|
||||
"""
|
||||
@spec get_stat_change(t(), atom()) :: integer()
|
||||
def get_stat_change(effect, stat) do
|
||||
case stat do
|
||||
:str -> effect.str
|
||||
:dex -> effect.dex
|
||||
:int -> effect.int
|
||||
:luk -> effect.luk
|
||||
:max_hp -> effect.mhp_r
|
||||
:max_mp -> effect.mmp_r
|
||||
:watk -> effect.watk
|
||||
:wdef -> effect.wdef
|
||||
:matk -> effect.matk
|
||||
:mdef -> effect.mdef
|
||||
:acc -> effect.acc
|
||||
:avoid -> effect.avoid
|
||||
:speed -> effect.speed
|
||||
:jump -> effect.jump
|
||||
_ -> 0
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Applies this effect to HP calculation.
|
||||
Returns the HP change (can be negative).
|
||||
"""
|
||||
@spec calc_hp_change(t(), integer(), boolean()) :: integer()
|
||||
def calc_hp_change(effect, max_hp, _primary) do
|
||||
hp_change = effect.hp
|
||||
|
||||
# Apply HP% recovery/consumption
|
||||
hp_change = hp_change + trunc(max_hp * effect.hp_r)
|
||||
|
||||
# Cap recovery to max HP
|
||||
min(hp_change, max_hp)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Applies this effect to MP calculation.
|
||||
Returns the MP change (can be negative).
|
||||
"""
|
||||
@spec calc_mp_change(t(), integer(), boolean()) :: integer()
|
||||
def calc_mp_change(effect, max_mp, _primary) do
|
||||
mp_change = effect.mp
|
||||
|
||||
# Apply MP% recovery/consumption
|
||||
mp_change = mp_change + trunc(max_mp * effect.mp_r)
|
||||
|
||||
# Cap recovery to max MP
|
||||
min(mp_change, max_mp)
|
||||
end
|
||||
|
||||
# Helper for map size
|
||||
defp stat_size(nil), do: 0
|
||||
defp stat_size(map) when is_map(map), do: stat_size(Map.keys(map))
|
||||
defp stat_size(list) when is_list(list), do: length(list)
|
||||
end
|
||||
411
lib/odinsea/game/timer.ex
Normal file
411
lib/odinsea/game/timer.ex
Normal file
@@ -0,0 +1,411 @@
|
||||
defmodule Odinsea.Game.Timer do
|
||||
@moduledoc """
|
||||
Timer system for scheduling game events.
|
||||
Ported from Java `server.Timer`.
|
||||
|
||||
Provides multiple timer types for different purposes:
|
||||
- WorldTimer - Global world events
|
||||
- MapTimer - Map-specific events
|
||||
- BuffTimer - Character buffs
|
||||
- EventTimer - Game events
|
||||
- CloneTimer - Character clones
|
||||
- EtcTimer - Miscellaneous
|
||||
- CheatTimer - Anti-cheat monitoring
|
||||
- PingTimer - Connection keep-alive
|
||||
- RedisTimer - Redis updates
|
||||
- EMTimer - Event manager
|
||||
- GlobalTimer - Global scheduled tasks
|
||||
|
||||
Each timer is a GenServer that manages scheduled tasks using
|
||||
`Process.send_after` for efficient Erlang VM scheduling.
|
||||
"""
|
||||
|
||||
require Logger
|
||||
|
||||
# ============================================================================
|
||||
# Task Struct (defined first for use in Base)
|
||||
# ============================================================================
|
||||
|
||||
defmodule Task do
|
||||
@moduledoc """
|
||||
Represents a scheduled task.
|
||||
|
||||
Fields:
|
||||
- id: Unique task identifier
|
||||
- type: :one_shot or :recurring
|
||||
- fun: The function to execute (arity 0)
|
||||
- repeat_time: For recurring tasks, interval in milliseconds
|
||||
- timer_ref: Reference to the Erlang timer
|
||||
"""
|
||||
defstruct [
|
||||
:id,
|
||||
:type,
|
||||
:fun,
|
||||
:repeat_time,
|
||||
:timer_ref
|
||||
]
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
id: pos_integer(),
|
||||
type: :one_shot | :recurring,
|
||||
fun: function(),
|
||||
repeat_time: non_neg_integer() | nil,
|
||||
timer_ref: reference()
|
||||
}
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Base Timer Implementation (GenServer) - Must be defined before timer types
|
||||
# ============================================================================
|
||||
|
||||
defmodule Base do
|
||||
@moduledoc """
|
||||
Base implementation for all timer types.
|
||||
Uses GenServer with Process.send_after for scheduling.
|
||||
"""
|
||||
|
||||
defmacro __using__(opts) do
|
||||
timer_name = Keyword.fetch!(opts, :name)
|
||||
|
||||
quote do
|
||||
use GenServer
|
||||
require Logger
|
||||
|
||||
alias Odinsea.Game.Timer.Task
|
||||
|
||||
# ============================================================================
|
||||
# Client API
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Starts the timer GenServer.
|
||||
"""
|
||||
def start_link(_opts \\ []) do
|
||||
GenServer.start_link(__MODULE__, %{}, name: __MODULE__)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Registers a recurring task that executes at fixed intervals.
|
||||
|
||||
## Parameters
|
||||
- `fun`: Function to execute (arity 0)
|
||||
- `repeat_time`: Interval in milliseconds between executions
|
||||
- `delay`: Initial delay in milliseconds before first execution (default: 0)
|
||||
|
||||
## Returns
|
||||
- `{:ok, task_id}` on success
|
||||
- `{:error, reason}` on failure
|
||||
"""
|
||||
def register(fun, repeat_time, delay \\ 0) when is_function(fun, 0) and is_integer(repeat_time) and repeat_time > 0 do
|
||||
GenServer.call(__MODULE__, {:register, fun, repeat_time, delay})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Schedules a one-shot task to execute after a delay.
|
||||
|
||||
## Parameters
|
||||
- `fun`: Function to execute (arity 0)
|
||||
- `delay`: Delay in milliseconds before execution
|
||||
|
||||
## Returns
|
||||
- `{:ok, task_id}` on success
|
||||
- `{:error, reason}` on failure
|
||||
"""
|
||||
def schedule(fun, delay) when is_function(fun, 0) and is_integer(delay) and delay >= 0 do
|
||||
GenServer.call(__MODULE__, {:schedule, fun, delay})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Schedules a one-shot task to execute at a specific timestamp.
|
||||
|
||||
## Parameters
|
||||
- `fun`: Function to execute (arity 0)
|
||||
- `timestamp`: Unix timestamp in milliseconds
|
||||
|
||||
## Returns
|
||||
- `{:ok, task_id}` on success
|
||||
- `{:error, reason}` on failure
|
||||
"""
|
||||
def schedule_at_timestamp(fun, timestamp) when is_function(fun, 0) and is_integer(timestamp) do
|
||||
delay = timestamp - System.system_time(:millisecond)
|
||||
schedule(fun, max(0, delay))
|
||||
end
|
||||
|
||||
@doc """
|
||||
Cancels a scheduled or recurring task.
|
||||
|
||||
## Parameters
|
||||
- `task_id`: The task ID returned from register/schedule
|
||||
|
||||
## Returns
|
||||
- `:ok` on success
|
||||
- `{:error, :not_found}` if task doesn't exist
|
||||
"""
|
||||
def cancel(task_id) do
|
||||
GenServer.call(__MODULE__, {:cancel, task_id})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Stops the timer and cancels all pending tasks.
|
||||
"""
|
||||
def stop do
|
||||
GenServer.stop(__MODULE__, :normal)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets information about all active tasks.
|
||||
"""
|
||||
def info do
|
||||
GenServer.call(__MODULE__, :info)
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# GenServer Callbacks
|
||||
# ============================================================================
|
||||
|
||||
@impl true
|
||||
def init(_) do
|
||||
Logger.debug("#{__MODULE__} started")
|
||||
{:ok, %{tasks: %{}, next_id: 1}}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:register, fun, repeat_time, delay}, _from, state) do
|
||||
task_id = state.next_id
|
||||
|
||||
# Schedule initial execution
|
||||
timer_ref = Process.send_after(self(), {:execute_recurring, task_id}, delay)
|
||||
|
||||
task = %Task{
|
||||
id: task_id,
|
||||
type: :recurring,
|
||||
fun: fun,
|
||||
repeat_time: repeat_time,
|
||||
timer_ref: timer_ref
|
||||
}
|
||||
|
||||
new_state = %{
|
||||
state
|
||||
| tasks: Map.put(state.tasks, task_id, task),
|
||||
next_id: task_id + 1
|
||||
}
|
||||
|
||||
{:reply, {:ok, task_id}, new_state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:schedule, fun, delay}, _from, state) do
|
||||
task_id = state.next_id
|
||||
|
||||
timer_ref = Process.send_after(self(), {:execute_once, task_id}, delay)
|
||||
|
||||
task = %Task{
|
||||
id: task_id,
|
||||
type: :one_shot,
|
||||
fun: fun,
|
||||
timer_ref: timer_ref
|
||||
}
|
||||
|
||||
new_state = %{
|
||||
state
|
||||
| tasks: Map.put(state.tasks, task_id, task),
|
||||
next_id: task_id + 1
|
||||
}
|
||||
|
||||
{:reply, {:ok, task_id}, new_state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:cancel, task_id}, _from, state) do
|
||||
case Map.pop(state.tasks, task_id) do
|
||||
{nil, _} ->
|
||||
{:reply, {:error, :not_found}, state}
|
||||
|
||||
{task, remaining_tasks} ->
|
||||
# Cancel the timer if it hasn't fired yet
|
||||
Process.cancel_timer(task.timer_ref)
|
||||
{:reply, :ok, %{state | tasks: remaining_tasks}}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call(:info, _from, state) do
|
||||
info = %{
|
||||
module: __MODULE__,
|
||||
task_count: map_size(state.tasks),
|
||||
tasks: state.tasks
|
||||
}
|
||||
|
||||
{:reply, info, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:execute_once, task_id}, state) do
|
||||
case Map.pop(state.tasks, task_id) do
|
||||
{nil, _} ->
|
||||
# Task was already cancelled
|
||||
{:noreply, state}
|
||||
|
||||
{task, remaining_tasks} ->
|
||||
# Execute the task with error handling
|
||||
execute_task(task)
|
||||
{:noreply, %{state | tasks: remaining_tasks}}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:execute_recurring, task_id}, state) do
|
||||
case Map.get(state.tasks, task_id) do
|
||||
nil ->
|
||||
# Task was cancelled
|
||||
{:noreply, state}
|
||||
|
||||
task ->
|
||||
# Execute the task with error handling
|
||||
execute_task(task)
|
||||
|
||||
# Reschedule the next execution
|
||||
new_timer_ref = Process.send_after(self(), {:execute_recurring, task_id}, task.repeat_time)
|
||||
|
||||
updated_task = %{task | timer_ref: new_timer_ref}
|
||||
new_tasks = Map.put(state.tasks, task_id, updated_task)
|
||||
|
||||
{:noreply, %{state | tasks: new_tasks}}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def terminate(_reason, state) do
|
||||
# Cancel all pending timers
|
||||
Enum.each(state.tasks, fn {_id, task} ->
|
||||
Process.cancel_timer(task.timer_ref)
|
||||
end)
|
||||
|
||||
Logger.debug("#{__MODULE__} stopped, cancelled #{map_size(state.tasks)} tasks")
|
||||
:ok
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Private Functions
|
||||
# ============================================================================
|
||||
|
||||
defp execute_task(task) do
|
||||
try do
|
||||
task.fun.()
|
||||
rescue
|
||||
exception ->
|
||||
Logger.error("#{__MODULE__} task #{task.id} failed: #{Exception.message(exception)}")
|
||||
Logger.debug("#{__MODULE__} task #{task.id} stacktrace: #{Exception.format_stacktrace()}")
|
||||
catch
|
||||
kind, reason ->
|
||||
Logger.error("#{__MODULE__} task #{task.id} crashed: #{kind} - #{inspect(reason)}")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Timer Types - Individual GenServer Modules (defined AFTER Base)
|
||||
# ============================================================================
|
||||
|
||||
defmodule WorldTimer do
|
||||
@moduledoc "Timer for global world events."
|
||||
use Odinsea.Game.Timer.Base, name: :world_timer
|
||||
end
|
||||
|
||||
defmodule MapTimer do
|
||||
@moduledoc "Timer for map-specific events."
|
||||
use Odinsea.Game.Timer.Base, name: :map_timer
|
||||
end
|
||||
|
||||
defmodule BuffTimer do
|
||||
@moduledoc "Timer for character buffs."
|
||||
use Odinsea.Game.Timer.Base, name: :buff_timer
|
||||
end
|
||||
|
||||
defmodule EventTimer do
|
||||
@moduledoc "Timer for game events."
|
||||
use Odinsea.Game.Timer.Base, name: :event_timer
|
||||
end
|
||||
|
||||
defmodule CloneTimer do
|
||||
@moduledoc "Timer for character clones."
|
||||
use Odinsea.Game.Timer.Base, name: :clone_timer
|
||||
end
|
||||
|
||||
defmodule EtcTimer do
|
||||
@moduledoc "Timer for miscellaneous tasks."
|
||||
use Odinsea.Game.Timer.Base, name: :etc_timer
|
||||
end
|
||||
|
||||
defmodule CheatTimer do
|
||||
@moduledoc "Timer for anti-cheat monitoring."
|
||||
use Odinsea.Game.Timer.Base, name: :cheat_timer
|
||||
end
|
||||
|
||||
defmodule PingTimer do
|
||||
@moduledoc "Timer for connection keep-alive pings."
|
||||
use Odinsea.Game.Timer.Base, name: :ping_timer
|
||||
end
|
||||
|
||||
defmodule RedisTimer do
|
||||
@moduledoc "Timer for Redis updates."
|
||||
use Odinsea.Game.Timer.Base, name: :redis_timer
|
||||
end
|
||||
|
||||
defmodule EMTimer do
|
||||
@moduledoc "Timer for event manager scheduling."
|
||||
use Odinsea.Game.Timer.Base, name: :em_timer
|
||||
end
|
||||
|
||||
defmodule GlobalTimer do
|
||||
@moduledoc "Timer for global scheduled tasks."
|
||||
use Odinsea.Game.Timer.Base, name: :global_timer
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Convenience Functions (Delegating to specific timers)
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Schedules a one-shot task on the EtcTimer (for general use).
|
||||
"""
|
||||
def schedule(fun, delay) when is_function(fun, 0) do
|
||||
EtcTimer.schedule(fun, delay)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Registers a recurring task on the EtcTimer (for general use).
|
||||
"""
|
||||
def register(fun, repeat_time, delay \\ 0) when is_function(fun, 0) do
|
||||
EtcTimer.register(fun, repeat_time, delay)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Cancels a task by ID on the EtcTimer.
|
||||
Note: For other timers, use TimerType.cancel(task_id) directly.
|
||||
"""
|
||||
def cancel(task_id) do
|
||||
EtcTimer.cancel(task_id)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns a list of all timer modules for supervision.
|
||||
"""
|
||||
def all_timer_modules do
|
||||
[
|
||||
WorldTimer,
|
||||
MapTimer,
|
||||
BuffTimer,
|
||||
EventTimer,
|
||||
CloneTimer,
|
||||
EtcTimer,
|
||||
CheatTimer,
|
||||
PingTimer,
|
||||
RedisTimer,
|
||||
EMTimer,
|
||||
GlobalTimer
|
||||
]
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user