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