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