multiplayer 0.11.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- multiplayer/IPClogging/__init__.py +42 -0
- multiplayer/IPClogging/echoing.py +34 -0
- multiplayer/IPClogging/server.py +338 -0
- multiplayer/IPClogging/test.py +71 -0
- multiplayer/__init__.py +52 -0
- multiplayer/client.py +531 -0
- multiplayer/data/__init__.py +1 -0
- multiplayer/data/cities.csv +103 -0
- multiplayer/data/countries.csv +152 -0
- multiplayer/data/egyptian_gods.csv +110 -0
- multiplayer/data/european_kings.csv +109 -0
- multiplayer/data/european_queens.csv +105 -0
- multiplayer/data/greek_gods.csv +122 -0
- multiplayer/data/planets_moons.csv +123 -0
- multiplayer/data/rivers.csv +103 -0
- multiplayer/data/roman_gods.csv +109 -0
- multiplayer/data/seas_oceans.csv +104 -0
- multiplayer/exceptions.py +39 -0
- multiplayer/game.py +275 -0
- multiplayer/language/__init__.py +23 -0
- multiplayer/language/language.py +445 -0
- multiplayer/py.typed +0 -0
- multiplayer/run_log_server.py +33 -0
- multiplayer/run_server.py +91 -0
- multiplayer/server.py +676 -0
- multiplayer/utils.py +215 -0
- multiplayer-0.11.0.dist-info/METADATA +284 -0
- multiplayer-0.11.0.dist-info/RECORD +31 -0
- multiplayer-0.11.0.dist-info/WHEEL +4 -0
- multiplayer-0.11.0.dist-info/entry_points.txt +4 -0
- multiplayer-0.11.0.dist-info/licenses/LICENSE.md +23 -0
multiplayer/server.py
ADDED
|
@@ -0,0 +1,676 @@
|
|
|
1
|
+
"""
|
|
2
|
+
This module provides the server-side implementation for networked multiplayer games.
|
|
3
|
+
"""
|
|
4
|
+
import socket
|
|
5
|
+
import json
|
|
6
|
+
import threading
|
|
7
|
+
import uuid
|
|
8
|
+
import struct
|
|
9
|
+
import ssl
|
|
10
|
+
import tempfile
|
|
11
|
+
import os
|
|
12
|
+
import logging
|
|
13
|
+
from multiprocessing import get_context
|
|
14
|
+
from datetime import datetime, timedelta, timezone
|
|
15
|
+
from cryptography import x509
|
|
16
|
+
from cryptography.x509.oid import NameOID
|
|
17
|
+
from cryptography.hazmat.primitives import hashes
|
|
18
|
+
from cryptography.hazmat.primitives.asymmetric import rsa
|
|
19
|
+
from cryptography.hazmat.primitives import serialization
|
|
20
|
+
import enum
|
|
21
|
+
|
|
22
|
+
from .game import Game, Player, Observer, GameState
|
|
23
|
+
from .exceptions import GameLogicError, PlayerLimitReachedError, ObserverLimitReachedError, AuthenticationError
|
|
24
|
+
|
|
25
|
+
# Custom JSON Encoder to handle enums
|
|
26
|
+
class EnumEncoder(json.JSONEncoder):
|
|
27
|
+
def default(self, obj):
|
|
28
|
+
if isinstance(obj, enum.Enum):
|
|
29
|
+
return obj.value
|
|
30
|
+
return super().default(obj)
|
|
31
|
+
|
|
32
|
+
# Constants for network discovery
|
|
33
|
+
MULTICAST_GROUP = '224.1.1.1'
|
|
34
|
+
DISCOVERY_PORT = 5007
|
|
35
|
+
DISCOVERY_MESSAGE = b'multiplayer_game_discovery_request'
|
|
36
|
+
RESPONSE_MESSAGE_FORMAT = b'!15sH' # 15-char IP, unsigned short port
|
|
37
|
+
|
|
38
|
+
def _generate_self_signed_cert(domain="localhost"):
|
|
39
|
+
"""Generates a temporary self-signed certificate and key."""
|
|
40
|
+
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
|
41
|
+
subject = issuer = x509.Name([
|
|
42
|
+
x509.NameAttribute(NameOID.COMMON_NAME, domain),
|
|
43
|
+
])
|
|
44
|
+
cert = x509.CertificateBuilder().subject_name(
|
|
45
|
+
subject
|
|
46
|
+
).issuer_name(
|
|
47
|
+
issuer
|
|
48
|
+
).public_key(
|
|
49
|
+
key.public_key()
|
|
50
|
+
).serial_number(
|
|
51
|
+
x509.random_serial_number()
|
|
52
|
+
).not_valid_before(
|
|
53
|
+
datetime.now(timezone.utc)
|
|
54
|
+
).not_valid_after(
|
|
55
|
+
datetime.now(timezone.utc) + timedelta(days=365)
|
|
56
|
+
).add_extension(
|
|
57
|
+
x509.SubjectAlternativeName([x509.DNSName(domain)]),
|
|
58
|
+
critical=False,
|
|
59
|
+
).sign(key, hashes.SHA256())
|
|
60
|
+
key_file = tempfile.NamedTemporaryFile(delete=False, suffix=".pem")
|
|
61
|
+
cert_file = tempfile.NamedTemporaryFile(delete=False, suffix=".pem")
|
|
62
|
+
key_file.write(key.private_bytes(
|
|
63
|
+
encoding=serialization.Encoding.PEM,
|
|
64
|
+
format=serialization.PrivateFormat.TraditionalOpenSSL,
|
|
65
|
+
encryption_algorithm=serialization.NoEncryption(),
|
|
66
|
+
))
|
|
67
|
+
cert_file.write(cert.public_bytes(serialization.Encoding.PEM))
|
|
68
|
+
key_file.close()
|
|
69
|
+
cert_file.close()
|
|
70
|
+
return cert_file.name, key_file.name
|
|
71
|
+
|
|
72
|
+
def get_cert_expiration(cert_path):
|
|
73
|
+
"""Returns the expiration date of a PEM certificate."""
|
|
74
|
+
try:
|
|
75
|
+
with open(cert_path, "rb") as f:
|
|
76
|
+
cert_data = f.read()
|
|
77
|
+
cert = x509.load_pem_x509_certificate(cert_data)
|
|
78
|
+
return cert.not_valid_after_utc.isoformat()
|
|
79
|
+
except Exception as e:
|
|
80
|
+
return f"Error reading certificate: {e}"
|
|
81
|
+
|
|
82
|
+
def _run_server_process(host, port, password, admin_password, use_tls, certfile, keyfile, logging_host=None, logging_port=None, logger_name="GameServer", name=None):
|
|
83
|
+
"""The main server loop that listens for and handles connections."""
|
|
84
|
+
logger = logging.getLogger(logger_name)
|
|
85
|
+
logger.setLevel(logging.INFO)
|
|
86
|
+
if logging_host and logging_port:
|
|
87
|
+
from logging.handlers import SocketHandler
|
|
88
|
+
# Remove existing SocketHandlers if any
|
|
89
|
+
for h in logger.handlers[:]:
|
|
90
|
+
if isinstance(h, SocketHandler):
|
|
91
|
+
logger.removeHandler(h)
|
|
92
|
+
handler = SocketHandler(logging_host, logging_port)
|
|
93
|
+
logger.addHandler(handler)
|
|
94
|
+
logger.info(f"Logging configured to send to {logging_host}:{logging_port}")
|
|
95
|
+
|
|
96
|
+
server_start_msg = f"Starting server process on {host}:{port}"
|
|
97
|
+
if name:
|
|
98
|
+
server_start_msg += f" (Name: {name})"
|
|
99
|
+
logger.info(server_start_msg)
|
|
100
|
+
games = {}
|
|
101
|
+
games_lock = threading.Lock()
|
|
102
|
+
context = None
|
|
103
|
+
if use_tls:
|
|
104
|
+
context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
|
|
105
|
+
context.minimum_version = ssl.TLSVersion.TLSv1_3
|
|
106
|
+
context.load_cert_chain(certfile=certfile, keyfile=keyfile)
|
|
107
|
+
bindsocket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
108
|
+
bindsocket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
109
|
+
bindsocket.bind((host, port))
|
|
110
|
+
bindsocket.listen()
|
|
111
|
+
groups = {} # Store GameGroup objects
|
|
112
|
+
server_passwords = {'server': password, 'admin': admin_password}
|
|
113
|
+
try:
|
|
114
|
+
bindsocket.settimeout(1.0)
|
|
115
|
+
while True:
|
|
116
|
+
try:
|
|
117
|
+
newsocket, fromaddr = bindsocket.accept()
|
|
118
|
+
except socket.timeout:
|
|
119
|
+
continue
|
|
120
|
+
try:
|
|
121
|
+
conn = context.wrap_socket(newsocket, server_side=True) if use_tls else newsocket
|
|
122
|
+
thread = threading.Thread(target=_handle_client, args=(conn, fromaddr, games, groups, games_lock, server_passwords, logger_name, name, use_tls, certfile))
|
|
123
|
+
thread.daemon = True
|
|
124
|
+
thread.start()
|
|
125
|
+
except (ssl.SSLError, OSError) as e:
|
|
126
|
+
print(f"Failed to wrap socket or start thread: {e}")
|
|
127
|
+
newsocket.close()
|
|
128
|
+
finally:
|
|
129
|
+
bindsocket.close()
|
|
130
|
+
# Clean up temporary files if they were created (indicated by "tmp" in filename or being specifically tracked)
|
|
131
|
+
# Note: self._temp_certs from GameServer is not available here, so we rely on path indicators or naming.
|
|
132
|
+
if use_tls and certfile and ("tmp" in certfile.lower() or "multiplayer_fullchain" in certfile.lower()):
|
|
133
|
+
try:
|
|
134
|
+
if os.path.exists(certfile):
|
|
135
|
+
os.remove(certfile)
|
|
136
|
+
# Only remove keyfile if it's also a temp file (like in self-signed case)
|
|
137
|
+
if keyfile and "tmp" in keyfile.lower() and os.path.exists(keyfile):
|
|
138
|
+
os.remove(keyfile)
|
|
139
|
+
except Exception:
|
|
140
|
+
pass
|
|
141
|
+
|
|
142
|
+
def _handle_client(conn, addr, games, groups, lock, server_passwords, logger_name="GameServer", server_name=None, use_tls=False, certfile=None):
|
|
143
|
+
"""Handles a single client connection."""
|
|
144
|
+
logger = logging.getLogger(logger_name)
|
|
145
|
+
logger.info(f"Connected by {addr}")
|
|
146
|
+
try:
|
|
147
|
+
with conn:
|
|
148
|
+
data = conn.recv(1024)
|
|
149
|
+
if not data:
|
|
150
|
+
return
|
|
151
|
+
try:
|
|
152
|
+
command = json.loads(data.decode('utf-8'))
|
|
153
|
+
client_password = command.get('password')
|
|
154
|
+
action = command.get('action')
|
|
155
|
+
params = command.get('params', {})
|
|
156
|
+
|
|
157
|
+
server_password = server_passwords.get('server')
|
|
158
|
+
admin_password = server_passwords.get('admin')
|
|
159
|
+
|
|
160
|
+
# Check if it's an admin action
|
|
161
|
+
is_server_admin_action = action in ['stop_server', 'restart_server', 'get_server_info', 'set_logging_config', 'set_logging_enabled', 'list_all_players', 'get_cert_expiration', 'set_server_password', 'set_admin_password', 'create_group', 'remove_group', 'list_groups']
|
|
162
|
+
is_group_admin_action = action in ['list_group_games', 'kick_player', 'kick_observer', 'set_group_admin_password', 'get_group_info']
|
|
163
|
+
|
|
164
|
+
# If it's a kick action, it could be server admin OR group admin
|
|
165
|
+
# If group_id is provided, we check group admin rights.
|
|
166
|
+
# If not, we check server admin rights.
|
|
167
|
+
group_id = params.get('group_id')
|
|
168
|
+
|
|
169
|
+
if is_server_admin_action:
|
|
170
|
+
if admin_password is None:
|
|
171
|
+
raise AuthenticationError("Admin actions are disabled on this server")
|
|
172
|
+
if client_password != admin_password:
|
|
173
|
+
raise AuthenticationError("Invalid admin password")
|
|
174
|
+
elif is_group_admin_action and group_id:
|
|
175
|
+
with lock:
|
|
176
|
+
group = None
|
|
177
|
+
for g in groups.values():
|
|
178
|
+
if g.ID == group_id:
|
|
179
|
+
group = g
|
|
180
|
+
break
|
|
181
|
+
if not group:
|
|
182
|
+
raise GameLogicError(f"Group with ID '{group_id}' not found")
|
|
183
|
+
if group.admin_password is None:
|
|
184
|
+
raise AuthenticationError(f"Group admin actions are disabled for group ID '{group_id}'")
|
|
185
|
+
if client_password != group.admin_password:
|
|
186
|
+
# Allow server admin to also act as group admin
|
|
187
|
+
if admin_password is None or client_password != admin_password:
|
|
188
|
+
raise AuthenticationError("Invalid group admin password")
|
|
189
|
+
elif is_group_admin_action: # Kick without group_name -> must be server admin
|
|
190
|
+
if admin_password is None:
|
|
191
|
+
raise AuthenticationError("Admin actions are disabled on this server")
|
|
192
|
+
if client_password != admin_password:
|
|
193
|
+
raise AuthenticationError("Invalid admin password")
|
|
194
|
+
elif server_password is not None and client_password != server_password:
|
|
195
|
+
raise AuthenticationError("Invalid server password")
|
|
196
|
+
|
|
197
|
+
with lock:
|
|
198
|
+
response = _execute_command(games, groups, action, params, server_name=server_name, use_tls=use_tls, certfile=certfile, server_passwords=server_passwords)
|
|
199
|
+
conn.sendall(json.dumps(response, cls=EnumEncoder).encode('utf-8'))
|
|
200
|
+
except (json.JSONDecodeError, TypeError, AuthenticationError, GameLogicError) as e:
|
|
201
|
+
error_response = {'status': 'error', 'type': type(e).__name__, 'message': str(e)}
|
|
202
|
+
conn.sendall(json.dumps(error_response, cls=EnumEncoder).encode('utf-8'))
|
|
203
|
+
finally:
|
|
204
|
+
logger.info(f"Disconnected from {addr}")
|
|
205
|
+
|
|
206
|
+
def _execute_command(games, groups, action, params, server_name=None, use_tls=False, certfile=None, server_passwords=None):
|
|
207
|
+
"""Executes a command on the game objects and returns a response."""
|
|
208
|
+
from .game import GameGroup
|
|
209
|
+
try:
|
|
210
|
+
# Server-level actions
|
|
211
|
+
if action == 'create_game':
|
|
212
|
+
game_id = str(uuid.uuid4())
|
|
213
|
+
# Ensure name is in params so it's part of attributes
|
|
214
|
+
game = Game(**params)
|
|
215
|
+
games[game_id] = game
|
|
216
|
+
|
|
217
|
+
# If group_id is provided, add game to group
|
|
218
|
+
group_id = params.get('group_id')
|
|
219
|
+
if group_id:
|
|
220
|
+
group = None
|
|
221
|
+
for g in groups.values():
|
|
222
|
+
if g.ID == group_id:
|
|
223
|
+
group = g
|
|
224
|
+
break
|
|
225
|
+
if group:
|
|
226
|
+
group.add_game(game)
|
|
227
|
+
|
|
228
|
+
return {'status': 'success', 'data': {'game_id': game_id, 'name': game.name}}
|
|
229
|
+
|
|
230
|
+
elif action == 'create_group':
|
|
231
|
+
group_name = params.get('name')
|
|
232
|
+
if not group_name:
|
|
233
|
+
return {'status': 'error', 'message': 'Missing group name'}
|
|
234
|
+
if group_name in groups:
|
|
235
|
+
return {'status': 'error', 'message': f'Group {group_name} already exists'}
|
|
236
|
+
admin_password = params.get('admin_password')
|
|
237
|
+
group = GameGroup(group_name, admin_password=admin_password, **params.get('attributes', {}))
|
|
238
|
+
groups[group_name] = group
|
|
239
|
+
return {'status': 'success', 'data': {'group_id': group.ID, 'name': group.name}}
|
|
240
|
+
|
|
241
|
+
elif action == 'remove_group':
|
|
242
|
+
group_id = params.get('group_id')
|
|
243
|
+
if not group_id:
|
|
244
|
+
return {'status': 'error', 'message': 'Missing group ID'}
|
|
245
|
+
|
|
246
|
+
target_name = None
|
|
247
|
+
for name, group in groups.items():
|
|
248
|
+
if group.ID == group_id:
|
|
249
|
+
target_name = name
|
|
250
|
+
break
|
|
251
|
+
|
|
252
|
+
if not target_name:
|
|
253
|
+
return {'status': 'error', 'message': f'Group with ID {group_id} not found'}
|
|
254
|
+
|
|
255
|
+
del groups[target_name]
|
|
256
|
+
return {'status': 'success', 'message': f'Group {target_name} removed'}
|
|
257
|
+
|
|
258
|
+
elif action == 'list_groups':
|
|
259
|
+
group_list = {}
|
|
260
|
+
for name, group in groups.items():
|
|
261
|
+
group_list[group.ID] = {
|
|
262
|
+
'name': group.name,
|
|
263
|
+
'attributes': group.attributes,
|
|
264
|
+
'games_count': len(group.games)
|
|
265
|
+
}
|
|
266
|
+
return {'status': 'success', 'data': group_list}
|
|
267
|
+
|
|
268
|
+
elif action == 'set_server_password':
|
|
269
|
+
new_password = params.get('new_password')
|
|
270
|
+
if server_passwords is not None:
|
|
271
|
+
server_passwords['server'] = new_password
|
|
272
|
+
return {'status': 'success', 'message': 'Server password updated'}
|
|
273
|
+
return {'status': 'error', 'message': 'Server passwords dictionary not available'}
|
|
274
|
+
|
|
275
|
+
elif action == 'set_admin_password':
|
|
276
|
+
new_password = params.get('new_password')
|
|
277
|
+
if server_passwords is not None:
|
|
278
|
+
server_passwords['admin'] = new_password
|
|
279
|
+
return {'status': 'success', 'message': 'Admin password updated'}
|
|
280
|
+
return {'status': 'error', 'message': 'Server passwords dictionary not available'}
|
|
281
|
+
|
|
282
|
+
elif action == 'set_group_admin_password':
|
|
283
|
+
group_id = params.get('group_id')
|
|
284
|
+
new_password = params.get('new_password')
|
|
285
|
+
group = None
|
|
286
|
+
for g in groups.values():
|
|
287
|
+
if g.ID == group_id:
|
|
288
|
+
group = g
|
|
289
|
+
break
|
|
290
|
+
if not group:
|
|
291
|
+
return {'status': 'error', 'message': f'Group with ID {group_id} not found'}
|
|
292
|
+
group.admin_password = new_password
|
|
293
|
+
return {'status': 'success', 'message': f'Admin password for group {group.name} updated'}
|
|
294
|
+
|
|
295
|
+
elif action == 'list_group_games':
|
|
296
|
+
group_id = params.get('group_id')
|
|
297
|
+
group = None
|
|
298
|
+
for g in groups.values():
|
|
299
|
+
if g.ID == group_id:
|
|
300
|
+
group = g
|
|
301
|
+
break
|
|
302
|
+
if not group:
|
|
303
|
+
return {'status': 'error', 'message': f'Group with ID {group_id} not found'}
|
|
304
|
+
# We need to find the GIDs for the games in the group
|
|
305
|
+
group_games = {}
|
|
306
|
+
for gid, g in games.items():
|
|
307
|
+
if g in group.games and g.state != GameState.FINISHED:
|
|
308
|
+
attrs = g.attributes.copy()
|
|
309
|
+
attrs['name'] = g.name
|
|
310
|
+
group_games[gid] = attrs
|
|
311
|
+
return {'status': 'success', 'data': group_games}
|
|
312
|
+
|
|
313
|
+
elif action == 'get_group_info':
|
|
314
|
+
group_id = params.get('group_id')
|
|
315
|
+
group = None
|
|
316
|
+
for g in groups.values():
|
|
317
|
+
if g.ID == group_id:
|
|
318
|
+
group = g
|
|
319
|
+
break
|
|
320
|
+
if not group:
|
|
321
|
+
return {'status': 'error', 'message': f'Group with ID {group_id} not found'}
|
|
322
|
+
return {
|
|
323
|
+
'status': 'success',
|
|
324
|
+
'data': {
|
|
325
|
+
'name': group.name,
|
|
326
|
+
'attributes': group.attributes,
|
|
327
|
+
'games_count': len(group.games)
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
elif action == 'list_games':
|
|
332
|
+
game_list = {}
|
|
333
|
+
for gid, g in games.items():
|
|
334
|
+
if g.state != GameState.FINISHED:
|
|
335
|
+
attrs = g.attributes.copy()
|
|
336
|
+
attrs['name'] = g.name
|
|
337
|
+
game_list[gid] = attrs
|
|
338
|
+
return {'status': 'success', 'data': game_list}
|
|
339
|
+
|
|
340
|
+
elif action == 'stop_server':
|
|
341
|
+
result = {'status': 'success', 'message': 'Server stopping...'}
|
|
342
|
+
def delayed_exit():
|
|
343
|
+
import time
|
|
344
|
+
time.sleep(0.5)
|
|
345
|
+
os._exit(0)
|
|
346
|
+
threading.Thread(target=delayed_exit).start()
|
|
347
|
+
return result
|
|
348
|
+
|
|
349
|
+
elif action == 'restart_server':
|
|
350
|
+
result = {'status': 'success', 'message': 'Server restarting...'}
|
|
351
|
+
def delayed_restart():
|
|
352
|
+
import time
|
|
353
|
+
time.sleep(0.5)
|
|
354
|
+
games.clear()
|
|
355
|
+
groups.clear()
|
|
356
|
+
threading.Thread(target=delayed_restart).start()
|
|
357
|
+
return result
|
|
358
|
+
|
|
359
|
+
elif action == 'get_server_info':
|
|
360
|
+
return {'status': 'success', 'data': {
|
|
361
|
+
'server_name': server_name,
|
|
362
|
+
'games_count': len(games),
|
|
363
|
+
'active_games': [gid for gid, g in games.items() if g.state != GameState.FINISHED]
|
|
364
|
+
}}
|
|
365
|
+
|
|
366
|
+
elif action == 'set_logging_config':
|
|
367
|
+
logging_host = params.get('host')
|
|
368
|
+
logging_port = params.get('port')
|
|
369
|
+
if logging_host and logging_port:
|
|
370
|
+
from logging.handlers import SocketHandler
|
|
371
|
+
logger = logging.getLogger("GameServer")
|
|
372
|
+
# Remove existing SocketHandlers if any to avoid duplicates
|
|
373
|
+
for h in logger.handlers[:]:
|
|
374
|
+
if isinstance(h, SocketHandler):
|
|
375
|
+
logger.removeHandler(h)
|
|
376
|
+
|
|
377
|
+
handler = SocketHandler(logging_host, logging_port)
|
|
378
|
+
logger.addHandler(handler)
|
|
379
|
+
logger.info(f"Logging reconfigured to send to {logging_host}:{logging_port}")
|
|
380
|
+
return {'status': 'success'}
|
|
381
|
+
else:
|
|
382
|
+
return {'status': 'error', 'message': 'Missing host or port'}
|
|
383
|
+
|
|
384
|
+
elif action == 'set_logging_enabled':
|
|
385
|
+
enabled = params.get('enabled', True)
|
|
386
|
+
logger = logging.getLogger("GameServer")
|
|
387
|
+
if enabled:
|
|
388
|
+
logger.setLevel(logging.INFO)
|
|
389
|
+
logger.info("Logging enabled")
|
|
390
|
+
else:
|
|
391
|
+
logger.info("Logging disabled")
|
|
392
|
+
logger.setLevel(logging.CRITICAL + 1) # Effectively disables all logging
|
|
393
|
+
return {'status': 'success'}
|
|
394
|
+
|
|
395
|
+
elif action == 'list_all_players':
|
|
396
|
+
all_players = []
|
|
397
|
+
for gid, game in games.items():
|
|
398
|
+
game_name = game.name or 'Unknown'
|
|
399
|
+
for player in game.players:
|
|
400
|
+
all_players.append({
|
|
401
|
+
'name': player.name,
|
|
402
|
+
'attributes': player.attributes,
|
|
403
|
+
'game_id': gid,
|
|
404
|
+
'game_name': game_name
|
|
405
|
+
})
|
|
406
|
+
return {'status': 'success', 'data': all_players}
|
|
407
|
+
|
|
408
|
+
elif action == 'get_cert_expiration':
|
|
409
|
+
if not use_tls or not certfile:
|
|
410
|
+
return {'status': 'error', 'message': 'TLS is not enabled or no certificate provided'}
|
|
411
|
+
expiration = get_cert_expiration(certfile)
|
|
412
|
+
return {'status': 'success', 'expiration': expiration}
|
|
413
|
+
|
|
414
|
+
# Game-specific actions
|
|
415
|
+
game_id = params.get('game_id')
|
|
416
|
+
if not game_id or game_id not in games:
|
|
417
|
+
return {'status': 'error', 'type': 'GameNotFoundError', 'message': 'Game not found'}
|
|
418
|
+
|
|
419
|
+
game = games[game_id]
|
|
420
|
+
|
|
421
|
+
if action == 'add_player':
|
|
422
|
+
player_data = params['player']
|
|
423
|
+
player = Player(player_data['name'], **player_data.get('attributes', {}))
|
|
424
|
+
game_password = params.get('game_password')
|
|
425
|
+
game.add_player(player, password=game_password)
|
|
426
|
+
return {'status': 'success'}
|
|
427
|
+
|
|
428
|
+
elif action == 'add_observer':
|
|
429
|
+
observer_data = params['observer']
|
|
430
|
+
observer = Observer(observer_data['name'], **observer_data.get('attributes', {}))
|
|
431
|
+
observer_password = params.get('observer_password')
|
|
432
|
+
game.add_observer(observer, password=observer_password)
|
|
433
|
+
return {'status': 'success'}
|
|
434
|
+
|
|
435
|
+
elif action == 'start':
|
|
436
|
+
game.start()
|
|
437
|
+
return {'status': 'success'}
|
|
438
|
+
|
|
439
|
+
elif action == 'pause':
|
|
440
|
+
game.pause()
|
|
441
|
+
return {'status': 'success'}
|
|
442
|
+
|
|
443
|
+
elif action == 'resume':
|
|
444
|
+
game.resume()
|
|
445
|
+
return {'status': 'success'}
|
|
446
|
+
|
|
447
|
+
elif action == 'stop':
|
|
448
|
+
game.stop()
|
|
449
|
+
return {'status': 'success'}
|
|
450
|
+
|
|
451
|
+
elif action == 'next_turn':
|
|
452
|
+
game.next_turn()
|
|
453
|
+
return {'status': 'success'}
|
|
454
|
+
|
|
455
|
+
elif action == 'get_current_player':
|
|
456
|
+
player = game.current_player
|
|
457
|
+
if player:
|
|
458
|
+
return {'status': 'success', 'data': {'name': player.name, 'attributes': player.attributes}}
|
|
459
|
+
else:
|
|
460
|
+
return {'status': 'success', 'data': None}
|
|
461
|
+
|
|
462
|
+
elif action == 'get_game_state':
|
|
463
|
+
return {'status': 'success', 'data': {'status': game.state, 'custom': game.custom_state}}
|
|
464
|
+
|
|
465
|
+
elif action == 'get_players':
|
|
466
|
+
player_list = [{'id': p.ID, 'name': p.name, 'attributes': p.attributes} for p in game.players]
|
|
467
|
+
return {'status': 'success', 'data': player_list}
|
|
468
|
+
|
|
469
|
+
elif action == 'get_observers':
|
|
470
|
+
observer_list = [{'id': o.ID, 'name': o.name, 'attributes': o.attributes} for o in game.observers]
|
|
471
|
+
return {'status': 'success', 'data': observer_list}
|
|
472
|
+
|
|
473
|
+
elif action == 'set_game_state':
|
|
474
|
+
game.custom_state = params.get('state')
|
|
475
|
+
return {'status': 'success'}
|
|
476
|
+
|
|
477
|
+
elif action == 'kick_player':
|
|
478
|
+
player_id = params.get('player_id')
|
|
479
|
+
group_id = params.get('group_id')
|
|
480
|
+
if group_id:
|
|
481
|
+
group = None
|
|
482
|
+
for g in groups.values():
|
|
483
|
+
if g.ID == group_id:
|
|
484
|
+
group = g
|
|
485
|
+
break
|
|
486
|
+
if not group or game not in group.games:
|
|
487
|
+
return {'status': 'error', 'message': f'Game {game_id} does not belong to group ID {group_id}'}
|
|
488
|
+
game.remove_player(player_id)
|
|
489
|
+
return {'status': 'success'}
|
|
490
|
+
|
|
491
|
+
elif action == 'kick_observer':
|
|
492
|
+
observer_id = params.get('observer_id')
|
|
493
|
+
group_id = params.get('group_id')
|
|
494
|
+
if group_id:
|
|
495
|
+
group = None
|
|
496
|
+
for g in groups.values():
|
|
497
|
+
if g.ID == group_id:
|
|
498
|
+
group = g
|
|
499
|
+
break
|
|
500
|
+
if not group or game not in group.games:
|
|
501
|
+
return {'status': 'error', 'message': f'Game {game_id} does not belong to group ID {group_id}'}
|
|
502
|
+
game.remove_observer(observer_id)
|
|
503
|
+
return {'status': 'success'}
|
|
504
|
+
|
|
505
|
+
else:
|
|
506
|
+
return {'status': 'error', 'type': 'ServerError', 'message': 'Unknown action'}
|
|
507
|
+
|
|
508
|
+
except (GameLogicError, PlayerLimitReachedError, ObserverLimitReachedError, AuthenticationError) as e:
|
|
509
|
+
return {'status': 'error', 'type': type(e).__name__, 'message': str(e)}
|
|
510
|
+
except Exception as e:
|
|
511
|
+
return {'status': 'error', 'type': 'ServerError', 'message': str(e)}
|
|
512
|
+
|
|
513
|
+
class GameServer:
|
|
514
|
+
"""
|
|
515
|
+
Manages multiple Game instances and handles network requests from clients.
|
|
516
|
+
"""
|
|
517
|
+
def __init__(self, host='0.0.0.0', port=65432, password=None, admin_password=None, use_tls=False, tls_domain="localhost", tls_cert=None, tls_key=None, tls_self_signed=True, logging_host=None, logging_port=None, logger_name="GameServer", name=None):
|
|
518
|
+
self.host = host
|
|
519
|
+
self.port = port
|
|
520
|
+
self.password = password
|
|
521
|
+
self.admin_password = admin_password
|
|
522
|
+
self.use_tls = use_tls
|
|
523
|
+
self.tls_domain = tls_domain
|
|
524
|
+
self.tls_cert = tls_cert
|
|
525
|
+
self.tls_key = tls_key
|
|
526
|
+
self.tls_self_signed = tls_self_signed
|
|
527
|
+
self.logging_host = logging_host
|
|
528
|
+
self.logging_port = logging_port
|
|
529
|
+
self.logger_name = logger_name
|
|
530
|
+
self.name = name
|
|
531
|
+
self._server_process = None
|
|
532
|
+
self._discovery_thread = None
|
|
533
|
+
self._stop_discovery = threading.Event()
|
|
534
|
+
self._temp_certs = False
|
|
535
|
+
|
|
536
|
+
def start(self):
|
|
537
|
+
"""Starts the game server and discovery service in separate processes/threads."""
|
|
538
|
+
if self._server_process and self._server_process.is_alive():
|
|
539
|
+
print("Server is already running.")
|
|
540
|
+
return
|
|
541
|
+
|
|
542
|
+
certfile, keyfile = (self.tls_cert, self.tls_key)
|
|
543
|
+
self._temp_certs = False
|
|
544
|
+
|
|
545
|
+
if self.use_tls:
|
|
546
|
+
if self.tls_self_signed:
|
|
547
|
+
print(f"Generating self-signed certificate for {self.tls_domain}...")
|
|
548
|
+
certfile, keyfile = _generate_self_signed_cert(self.tls_domain)
|
|
549
|
+
self._temp_certs = True
|
|
550
|
+
elif not certfile or not keyfile:
|
|
551
|
+
# If one is provided but not the other, and self_signed is False, it's an error
|
|
552
|
+
if certfile or keyfile:
|
|
553
|
+
print("Error: Both tls_cert and tls_key must be provided if tls_self_signed is False.")
|
|
554
|
+
return
|
|
555
|
+
# If neither is provided, fallback to self-signed but warn
|
|
556
|
+
print(f"Warning: No certificate provided and tls_self_signed is False. Generating self-signed certificate anyway for {self.tls_domain}...")
|
|
557
|
+
certfile, keyfile = _generate_self_signed_cert(self.tls_domain)
|
|
558
|
+
self._temp_certs = True
|
|
559
|
+
else:
|
|
560
|
+
if not os.path.exists(certfile) or not os.path.exists(keyfile):
|
|
561
|
+
print(f"Error: Certificate file {certfile} or key file {keyfile} not found.")
|
|
562
|
+
return
|
|
563
|
+
|
|
564
|
+
# Auto-detect chain file
|
|
565
|
+
# If cert is 'cert.pem', looks for 'chain.pem'
|
|
566
|
+
# If cert is 'ECC-cert.pem', looks for 'ECC-chain.pem'
|
|
567
|
+
# If cert is 'RSA-cert.pem', looks for 'RSA-chain.pem'
|
|
568
|
+
cert_dir = os.path.dirname(os.path.abspath(certfile))
|
|
569
|
+
cert_name = os.path.basename(certfile)
|
|
570
|
+
if "-cert.pem" in cert_name:
|
|
571
|
+
chain_name = cert_name.replace("-cert.pem", "-chain.pem")
|
|
572
|
+
elif cert_name == "cert.pem":
|
|
573
|
+
chain_name = "chain.pem"
|
|
574
|
+
else:
|
|
575
|
+
chain_name = None
|
|
576
|
+
|
|
577
|
+
if chain_name:
|
|
578
|
+
chain_path = os.path.join(cert_dir, chain_name)
|
|
579
|
+
if os.path.exists(chain_path):
|
|
580
|
+
print(f"Found matching chain file: {chain_name}. Creating full chain...")
|
|
581
|
+
try:
|
|
582
|
+
with tempfile.NamedTemporaryFile(delete=False, suffix=".pem", prefix="multiplayer_fullchain_") as tmp_fullchain:
|
|
583
|
+
with open(certfile, 'rb') as f_cert:
|
|
584
|
+
tmp_fullchain.write(f_cert.read())
|
|
585
|
+
if not tmp_fullchain.tell() == 0: # Ensure newline between certs if needed
|
|
586
|
+
tmp_fullchain.write(b"\n")
|
|
587
|
+
with open(chain_path, 'rb') as f_chain:
|
|
588
|
+
tmp_fullchain.write(f_chain.read())
|
|
589
|
+
certfile = tmp_fullchain.name
|
|
590
|
+
except Exception as e:
|
|
591
|
+
print(f"Warning: Failed to create temporary full chain file: {e}. Using original certificate.")
|
|
592
|
+
|
|
593
|
+
# Use 'spawn' start method to avoid DeprecationWarning and potential deadlocks when forking from a multi-threaded process.
|
|
594
|
+
ctx = get_context('spawn')
|
|
595
|
+
self._server_process = ctx.Process(target=_run_server_process, args=(self.host, self.port, self.password, self.admin_password, self.use_tls, certfile, keyfile, self.logging_host, self.logging_port, self.logger_name, self.name))
|
|
596
|
+
self._server_process.daemon = True
|
|
597
|
+
self._server_process.start()
|
|
598
|
+
self._stop_discovery.clear()
|
|
599
|
+
self._discovery_thread = threading.Thread(target=self._run_discovery_service)
|
|
600
|
+
self._discovery_thread.daemon = True
|
|
601
|
+
self._discovery_thread.start()
|
|
602
|
+
start_msg = f"Server started on {self.host}:{self.port} with PID {self._server_process.pid}"
|
|
603
|
+
if self.name:
|
|
604
|
+
start_msg += f" (Name: {self.name})"
|
|
605
|
+
print(start_msg)
|
|
606
|
+
if self.use_tls:
|
|
607
|
+
print("TLS encryption is enabled.")
|
|
608
|
+
print("Network discovery service started.")
|
|
609
|
+
|
|
610
|
+
def stop(self, timeout=5):
|
|
611
|
+
"""Stops the game server and discovery service."""
|
|
612
|
+
if self._server_process and self._server_process.is_alive():
|
|
613
|
+
self._server_process.terminate()
|
|
614
|
+
self._server_process.join(timeout=timeout)
|
|
615
|
+
if self._server_process.is_alive():
|
|
616
|
+
print("Server process did not terminate gracefully, killing it...")
|
|
617
|
+
self._server_process.kill()
|
|
618
|
+
self._server_process.join()
|
|
619
|
+
print("Server stopped.")
|
|
620
|
+
else:
|
|
621
|
+
print("Server is not running.")
|
|
622
|
+
if self._discovery_thread and self._discovery_thread.is_alive():
|
|
623
|
+
self._stop_discovery.set()
|
|
624
|
+
self._discovery_thread.join(timeout=timeout)
|
|
625
|
+
print("Network discovery service stopped.")
|
|
626
|
+
|
|
627
|
+
def _run_discovery_service(self):
|
|
628
|
+
"""Listens for multicast discovery messages and responds."""
|
|
629
|
+
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) as sock:
|
|
630
|
+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
631
|
+
# SO_REUSEPORT is necessary for some OS (like MacOS) when binding to the same port
|
|
632
|
+
if hasattr(socket, 'SO_REUSEPORT'):
|
|
633
|
+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
|
|
634
|
+
|
|
635
|
+
try:
|
|
636
|
+
sock.bind(('', DISCOVERY_PORT))
|
|
637
|
+
except OSError as e:
|
|
638
|
+
logging.getLogger(self.logger_name).error(f"Failed to bind discovery service to port {DISCOVERY_PORT}: {e}")
|
|
639
|
+
return
|
|
640
|
+
|
|
641
|
+
mreq = struct.pack("4sl", socket.inet_aton(MULTICAST_GROUP), socket.INADDR_ANY)
|
|
642
|
+
try:
|
|
643
|
+
sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq)
|
|
644
|
+
except OSError as e:
|
|
645
|
+
logging.getLogger(self.logger_name).error(f"Failed to join multicast group: {e}")
|
|
646
|
+
return
|
|
647
|
+
|
|
648
|
+
sock.settimeout(1.0)
|
|
649
|
+
while not self._stop_discovery.is_set():
|
|
650
|
+
try:
|
|
651
|
+
data, addr = sock.recvfrom(1024)
|
|
652
|
+
if data == DISCOVERY_MESSAGE:
|
|
653
|
+
logger = logging.getLogger(self.logger_name)
|
|
654
|
+
logger.info(f"Discovery request from {addr}, sending response...")
|
|
655
|
+
response_ip = self._get_lan_ip()
|
|
656
|
+
response_port = self.port
|
|
657
|
+
message = struct.pack(RESPONSE_MESSAGE_FORMAT, response_ip.encode('utf-8'), response_port)
|
|
658
|
+
sock.sendto(message, addr)
|
|
659
|
+
except socket.timeout:
|
|
660
|
+
continue
|
|
661
|
+
except Exception as e:
|
|
662
|
+
logging.getLogger(self.logger_name).error(f"Error in discovery service: {e}")
|
|
663
|
+
|
|
664
|
+
def _get_lan_ip(self):
|
|
665
|
+
"""Helper to get the local LAN IP address."""
|
|
666
|
+
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
667
|
+
try:
|
|
668
|
+
# doesn't even have to be reachable
|
|
669
|
+
s.connect(('10.255.255.255', 1))
|
|
670
|
+
ip = s.getsockname()[0]
|
|
671
|
+
except Exception:
|
|
672
|
+
ip = '127.0.0.1'
|
|
673
|
+
finally:
|
|
674
|
+
s.close()
|
|
675
|
+
return ip
|
|
676
|
+
|