445 lines
13 KiB
Elixir
445 lines
13 KiB
Elixir
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
|