Skip to content

Commit bd53ef1

Browse files
committed
feat: Implemented area chat
1 parent 1fa6f23 commit bd53ef1

File tree

8 files changed

+1144
-6
lines changed

8 files changed

+1144
-6
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ rathena.xml
88
.DS_Store
99
.mnesia
1010
.memory_bank
11+
.claude_consciousness.m8
1112

1213
apps/zone_server/priv/db/rathena
1314

apps/zone_server/lib/aesir/zone_server/packet_registry.ex

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,11 @@ defmodule Aesir.ZoneServer.PacketRegistry do
1010
Aesir.ZoneServer.Packets.CzReqname2,
1111
Aesir.ZoneServer.Packets.CzSeCashshopList,
1212
Aesir.ZoneServer.Packets.CzPingLive,
13+
Aesir.ZoneServer.Packets.CzRequestChat,
1314
# Server to Client packets
1415
Aesir.ZoneServer.Packets.ZcAcceptEnter,
1516
Aesir.ZoneServer.Packets.ZcAid,
17+
Aesir.ZoneServer.Packets.ZcNotifyChat,
1618
Aesir.ZoneServer.Packets.ZcAckReqname,
1719
Aesir.ZoneServer.Packets.ZcNotifyTime,
1820
Aesir.ZoneServer.Packets.ZcNotifyTime2,
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
defmodule Aesir.ZoneServer.Packets.CzRequestChat do
2+
@moduledoc """
3+
Client to Server: Request to send a normal chat message.
4+
Packet ID: 0x008c
5+
"""
6+
7+
use Aesir.Commons.Network.Packet
8+
use TypedStruct
9+
10+
@packet_id 0x008C
11+
@packet_size :variable
12+
@packet_size_header 4
13+
14+
typedstruct do
15+
@typedoc "Client chat message request"
16+
field :message, String.t()
17+
end
18+
19+
@impl true
20+
def packet_id, do: @packet_id
21+
22+
@impl true
23+
def packet_size, do: @packet_size
24+
25+
@impl true
26+
def parse(
27+
<<@packet_id::16-little, packet_length::16-little,
28+
message_data::binary-size(packet_length - @packet_size_header)>>
29+
) do
30+
message = message_data |> to_string() |> String.trim_trailing("\0")
31+
{:ok, %__MODULE__{message: message}}
32+
end
33+
34+
def parse(_), do: {:error, :invalid_packet}
35+
36+
@impl true
37+
def build(%__MODULE__{} = _packet) do
38+
raise "CzRequestChat is a client-to-server packet and should not be built by the server."
39+
end
40+
end
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
defmodule Aesir.ZoneServer.Packets.ZcNotifyChat do
2+
@moduledoc """
3+
Server to Client: Notifies clients of a chat message in the area.
4+
Packet ID: 0x008d
5+
"""
6+
7+
use Aesir.Commons.Network.Packet
8+
use TypedStruct
9+
10+
@packet_id 0x008D
11+
@packet_size :variable
12+
@packet_size_header 4
13+
14+
typedstruct do
15+
@typedoc "Chat message broadcast to clients"
16+
field :gid, non_neg_integer()
17+
field :message, String.t()
18+
end
19+
20+
@impl true
21+
def packet_id, do: @packet_id
22+
23+
@impl true
24+
def packet_size, do: @packet_size
25+
26+
@impl true
27+
def parse(
28+
<<@packet_id::16-little, packet_length::16-little, gid::32-little,
29+
message_data::binary-size(packet_length - @packet_size_header - 4)>>
30+
) do
31+
message = message_data |> to_string() |> String.trim_trailing("\0")
32+
{:ok, %__MODULE__{gid: gid, message: message}}
33+
end
34+
35+
def parse(_), do: {:error, :invalid_packet}
36+
37+
@impl true
38+
def build(%__MODULE__{gid: gid, message: message}) do
39+
message_with_null = message <> <<0>>
40+
message_length = byte_size(message_with_null)
41+
packet_length = @packet_size_header + 4 + message_length
42+
43+
<<@packet_id::16-little, packet_length::16-little, gid::32-little, message_with_null::binary>>
44+
end
45+
end
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
defmodule Aesir.ZoneServer.Unit.Broadcast do
2+
@moduledoc """
3+
Helper functions for broadcasting packets to entities in the zone server.
4+
"""
5+
require Logger
6+
alias Aesir.ZoneServer.Unit.UnitRegistry
7+
8+
@doc """
9+
Broadcasts a packet to all visible players in the game state.
10+
11+
## Options
12+
- `exclude_id`: The entity ID to exclude from the broadcast (e.g., the sender).
13+
"""
14+
@spec to_visible_players(map(), struct(), keyword()) :: :ok
15+
def to_visible_players(game_state, packet, opts \\ []) do
16+
exclude_id = Keyword.get(opts, :exclude_id)
17+
18+
game_state.visible_players
19+
|> Enum.reject(&(&1 == exclude_id))
20+
|> Enum.each(&send_to_player(&1, packet))
21+
end
22+
23+
defp send_to_player(player_id, packet) do
24+
case UnitRegistry.get_player_pid(player_id) do
25+
{:ok, pid} ->
26+
GenServer.cast(pid, {:send_packet, packet})
27+
28+
{:error, :not_found} ->
29+
Logger.debug("Visible player #{player_id} not found in registry during broadcast.")
30+
end
31+
end
32+
end

apps/zone_server/lib/aesir/zone_server/unit/player/handlers/packet_handler.ex

Lines changed: 63 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,30 @@ defmodule Aesir.ZoneServer.Unit.Player.Handlers.PacketHandler do
88

99
alias Aesir.Commons.StatusParams
1010
alias Aesir.Commons.Utils.ServerTick
11+
alias Aesir.ZoneServer.Packets.CzRequestChat
1112
alias Aesir.ZoneServer.Packets.ZcAckReqname
1213
alias Aesir.ZoneServer.Packets.ZcAckReqnameall
1314
alias Aesir.ZoneServer.Packets.ZcEquipitemList
1415
alias Aesir.ZoneServer.Packets.ZcLongparChange
1516
alias Aesir.ZoneServer.Packets.ZcNormalItemlist
17+
alias Aesir.ZoneServer.Packets.ZcNotifyChat
1618
alias Aesir.ZoneServer.Packets.ZcNotifyTime
1719
alias Aesir.ZoneServer.Packets.ZcParChange
20+
alias Aesir.ZoneServer.Unit.Broadcast
1821
alias Aesir.ZoneServer.Unit.UnitRegistry
1922

23+
# Chat constants
24+
@chat_max_size 255
25+
26+
# Packet IDs
27+
@cz_notify_actorinit 0x007D
28+
@cz_request_time 0x007E
29+
@cz_request_time2 0x0360
30+
@cz_reqname2 0x0368
31+
@cz_request_move 0x035F
32+
@cz_request_act 0x0437
33+
@cz_request_chat 0x008C
34+
2035
@doc """
2136
Processes an incoming packet for a player session.
2237
@@ -33,7 +48,7 @@ defmodule Aesir.ZoneServer.Unit.Player.Handlers.PacketHandler do
3348

3449
# CZ_NOTIFY_ACTORINIT - Player finished loading map
3550
def handle_packet(
36-
0x007D,
51+
@cz_notify_actorinit,
3752
_packet_data,
3853
%{character: character, connection_pid: connection_pid, game_state: game_state} = state
3954
) do
@@ -75,7 +90,7 @@ defmodule Aesir.ZoneServer.Unit.Player.Handlers.PacketHandler do
7590
end
7691

7792
# CZ_REQUEST_TIME - Client requesting server time
78-
def handle_packet(0x007E, _packet_data, %{connection_pid: connection_pid} = state) do
93+
def handle_packet(@cz_request_time, _packet_data, %{connection_pid: connection_pid} = state) do
7994
server_tick = ServerTick.now()
8095

8196
packet = %ZcNotifyTime{
@@ -87,7 +102,7 @@ defmodule Aesir.ZoneServer.Unit.Player.Handlers.PacketHandler do
87102
end
88103

89104
# CZ_REQUEST_TIME2 - Alternative client time request
90-
def handle_packet(0x0360, _packet_data, %{connection_pid: connection_pid} = state) do
105+
def handle_packet(@cz_request_time2, _packet_data, %{connection_pid: connection_pid} = state) do
91106
server_tick = ServerTick.now()
92107

93108
packet = %ZcNotifyTime{
@@ -100,7 +115,7 @@ defmodule Aesir.ZoneServer.Unit.Player.Handlers.PacketHandler do
100115

101116
# CZ_REQNAME2 - Client requesting entity name
102117
def handle_packet(
103-
0x0368,
118+
@cz_reqname2,
104119
packet_data,
105120
%{
106121
character: character,
@@ -165,13 +180,13 @@ defmodule Aesir.ZoneServer.Unit.Player.Handlers.PacketHandler do
165180
end
166181

167182
# CZ_REQUEST_MOVE - Player movement request
168-
def handle_packet(0x035F, packet_data, state) do
183+
def handle_packet(@cz_request_move, packet_data, state) do
169184
GenServer.cast(self(), {:request_move, packet_data.dest_x, packet_data.dest_y})
170185
{:noreply, state}
171186
end
172187

173188
# CZ_REQUEST_ACT - Player action request (attack, sit, stand, etc.)
174-
def handle_packet(0x0437, packet_data, state) do
189+
def handle_packet(@cz_request_act, packet_data, state) do
175190
case packet_data.action do
176191
action when action in [0, 7] ->
177192
# Attack actions (0 = single attack, 7 = continuous attack)
@@ -192,6 +207,48 @@ defmodule Aesir.ZoneServer.Unit.Player.Handlers.PacketHandler do
192207
{:noreply, state}
193208
end
194209

210+
# CZ_REQUEST_CHAT - Player sending an area chat message
211+
def handle_packet(
212+
@cz_request_chat,
213+
%CzRequestChat{message: raw_message},
214+
%{character: character, game_state: game_state, connection_pid: connection_pid} = state
215+
) do
216+
# 1. Message Validation
217+
# Max chat size from rAthena is 256 bytes (including null terminator)
218+
if byte_size(raw_message) > @chat_max_size do
219+
Logger.warning("Player #{character.id} sent a message exceeding maximum length.")
220+
# Optionally send an error message to the client
221+
{:noreply, state}
222+
else
223+
# rAthena expects "CharName : Message"
224+
# We need to extract the actual message and validate the prefix
225+
name_prefix = character.name <> " : "
226+
227+
if String.starts_with?(raw_message, name_prefix) do
228+
chat_message = raw_message
229+
230+
# 2. Construct ZcNotifyChat packet
231+
packet = %ZcNotifyChat{
232+
gid: character.id,
233+
message: chat_message
234+
}
235+
236+
# 3. Broadcasting
237+
# To self
238+
send(connection_pid, {:send_packet, packet})
239+
240+
# To visible players (excluding self)
241+
Broadcast.to_visible_players(game_state, packet, exclude_id: character.id)
242+
else
243+
Logger.warning(
244+
"Player #{character.id} sent a malformed chat message (expected '#{name_prefix}'). Message: '#{raw_message}'"
245+
)
246+
end
247+
248+
{:noreply, state}
249+
end
250+
end
251+
195252
# Fallback for unknown packets
196253
def handle_packet(packet_id, _packet_data, state) do
197254
Logger.warning("Unhandled packet in PacketHandler: 0x#{Integer.to_string(packet_id, 16)}")

0 commit comments

Comments
 (0)