kimi gone wild

This commit is contained in:
ra
2026-02-14 23:12:33 -07:00
parent bbd205ecbe
commit 0222be36c5
98 changed files with 39726 additions and 309 deletions

View 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

View File

@@ -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

View 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
View 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

View 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

View 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
View 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

View 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
View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View File

@@ -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

View File

@@ -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

View 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

View 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

View File

@@ -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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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
View 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

View 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

View 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
View 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

View 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

View 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

View 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
View 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

View 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

View 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

View 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
View 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

View 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

View 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
View 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