-
Notifications
You must be signed in to change notification settings - Fork 0
/
server_enet.py
executable file
·192 lines (157 loc) · 6.57 KB
/
server_enet.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
#!/usr/bin/env python
import struct
from threading import Thread
from typing import NewType, Dict
from queue import Queue
import enet
import uuid
# Type Aliases
Channel = Queue
Host = NewType('Host', str)
Port = NewType('Port', int)
# SERVER CONFIGURATION
# enet should be able to handle 4K peers at once, but I'm not sure what happens when more than 10
# try to connect with the configuration below. I'm not sure how to handle bandwidth issues. It's not
# clear if setting bandwidth is preferable.
SERVER_IP: bytes = b"127.0.0.1"
SERVER_PORT: int = 12345
PEER_COUNT: int = 10
ENET_PROTOCOL_MAXIMUM_CHANNEL_COUNT: int = 255
INCOMING_BANDWIDTH = 0 # unlimited bandwidth
OUTGOING_BANDWIDTH = 0 # unlimited bandwidth
# COMMUNICATION PROTOCOL
# Matchmaking Sequence Diagram:
# Client 1 -> REQ
# ACK <- Server
# ...
# Client 2 -> REQ
# ACK <- Server
# Client 1, 2
# COMP [match_id] <- Server
MATCHMAKING_REQ = 0b0001
MATCHMAKING_ACK = 0b0010
MATCHMAKING_COMP = 0b0011
# Game Logic Sequence Diagram
# Client 1 -> ROCK [match_id]
# ...
# Client 2 -> PAPER [match_id]
# Client 1
# PAPER <- Server
# Client 2
# ROCK <- Server
ROCK, PAPER, SCISSORS = 0b0100, 0b0101, 0b0110
OPPONENT_NOT_CONNECTED = 0b0111
# Matches are stored in-memory. A server crashing would be fatal.
matches = dict()
def rock_paper_scissors(event: enet.Event, choice: int, data: bytes):
if not data:
print(f"unexpected error: no data to unpack")
return # throwing an exception would crash the server, so just log the error
# Lua appears to send a null terminated string. We'll just specify the exact size.
_, match_id = struct.unpack('!B 16s', data[:17])
match = matches.get(match_id, None)
if match is None:
print(f"unexpected error: match not found: {choice} {data}")
return
peer1, choice1, peer2, choice2 = match[0][0], match[0][1], match[1][0], match[1][1]
if choice1 and choice2:
print("unexpected error: both game choices are already populated")
return
elif peer1.address == event.peer.address and choice2:
match[0][1] = choice
elif peer2.address == event.peer.address and choice1:
match[1][1] = choice
elif peer1.address == event.peer.address:
match[0][1] = choice
elif peer2.address == event.peer.address:
match[1][1] = choice
else:
print("unexpected error: event peer address does not match either peer")
return
choice1, choice2 = match[0][1], match[1][1]
if choice1 and choice2:
peer1.send(0, enet.Packet(struct.pack('!B', choice2), enet.PACKET_FLAG_RELIABLE))
peer2.send(0, enet.Packet(struct.pack('!B', choice1), enet.PACKET_FLAG_RELIABLE))
match[0][1], match[1][1] = None, None
def pair_match(matchmaking: Channel):
while True:
client1, client2 = matchmaking.get(), matchmaking.get()
# This works if the client shuts down correctly. A zombie peer will eventually be
# disconnected as well, but it may still be perceived as "connected" at this stage.
if client1.peer.state is not enet.PEER_STATE_CONNECTED:
matchmaking.put(client2)
continue
if client2.peer.state is not enet.PEER_STATE_CONNECTED:
matchmaking.put(client1)
continue
# Uniqueness is determined by the client address port combination. These are not good
# identifiers because they can be spoofed, but it works for the sake of this prototype.
if client1.peer.address == client2.peer.address:
matchmaking.put(client1)
continue
match_id = uuid.uuid1()
matches[match_id.bytes] = [[client1.peer, None], [client2.peer, None]]
print(f"Created match: {match_id.hex}")
data = struct.pack('!B 16s', MATCHMAKING_COMP, match_id.bytes)
client1.peer.send(0, enet.Packet(data, enet.PACKET_FLAG_RELIABLE))
client2.peer.send(0, enet.Packet(data, enet.PACKET_FLAG_RELIABLE))
# Iterating over the matches may seem like an odd way to remove the client at first glance, but
# we can't rely on the client to send the match ID when it disconnects (for example, due to the
# game crashing). Clients are not trusted (hacked clients may exist), so we don't want them to
# affect the server.
#
# A data structure could be created to relate the address to the match_id in a separate reverse
# lookup dictionary, but this does not seem necessary to implement.
def remove_client(address: enet.Address):
packet = enet.Packet(struct.pack('!B', OPPONENT_NOT_CONNECTED), enet.PACKET_FLAG_RELIABLE)
for key, peer1, peer2 in ((key, value[0][0], value[1][0]) for key, value in matches.items()):
if peer1.address == address:
peer2.send(0, packet)
matches.pop(key, None)
return
if peer2.address == address:
peer1.send(0, packet)
matches.pop(key, None)
return
def handle_request(ch: Dict[str, Channel], event: enet.Event):
# A tuple is technically returned but there is only one value
protocol, = struct.unpack('!B', event.packet.data[:1])
if protocol == MATCHMAKING_REQ:
data = struct.pack('!B', MATCHMAKING_ACK)
event.peer.send(0, enet.Packet(data, enet.PACKET_FLAG_RELIABLE))
ch['matchmaking'].put(event)
elif protocol in [ROCK, PAPER, SCISSORS]:
rock_paper_scissors(event, protocol, event.packet.data)
else:
print(f"{event.peer.address}: invalid protocol data: {event.packet.data}")
def listen(ch: Dict[str, Channel], host):
event = host.service(100)
if event.type == enet.EVENT_TYPE_CONNECT:
print(f"{event.peer.address}: Client connected")
elif event.type == enet.EVENT_TYPE_DISCONNECT:
print(f"{event.peer.address}: Client disconnected")
remove_client(event.peer.address)
elif event.type == enet.EVENT_TYPE_RECEIVE:
print(f"Receive from {event.peer.address}::{event.channelID}")
handle_request(ch, event)
def main():
server = enet.Host(
enet.Address(SERVER_IP, SERVER_PORT),
PEER_COUNT,
ENET_PROTOCOL_MAXIMUM_CHANNEL_COUNT,
INCOMING_BANDWIDTH,
OUTGOING_BANDWIDTH)
data_channels = {
'matchmaking': Channel(),
}
matchmaking = Thread(target=pair_match, args=(data_channels['matchmaking'],))
matchmaking.daemon = True
matchmaking.start()
print(f"ENet Server listening on {SERVER_IP}:{SERVER_PORT}")
try:
while True:
listen(data_channels, server)
except KeyboardInterrupt:
print()
if __name__ == "__main__":
main()