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/client.py ADDED
@@ -0,0 +1,531 @@
1
+ """
2
+ This module provides the client-side implementation for networked multiplayer games.
3
+ """
4
+ import socket
5
+ import json
6
+ import struct
7
+ import time
8
+ import ssl
9
+ import logging
10
+ from logging.handlers import SocketHandler
11
+ from .game import Player, Observer
12
+ from . import exceptions
13
+
14
+ # Constants for network discovery
15
+ MULTICAST_GROUP = '224.1.1.1'
16
+ DISCOVERY_PORT = 5007
17
+ DISCOVERY_MESSAGE = b'multiplayer_game_discovery_request'
18
+ RESPONSE_MESSAGE_FORMAT = b'!15sH' # 15-char IP, unsigned short port
19
+
20
+ class GameClient:
21
+ """
22
+ A client for connecting to a GameServer.
23
+ """
24
+ def __init__(self, host='127.0.0.1', port=65432, password=None, use_tls=False):
25
+ self.host = host
26
+ self.port = port
27
+ self.password = password
28
+ self.use_tls = use_tls
29
+ self._logger = logging.getLogger("GameClient")
30
+ self._logger.setLevel(logging.INFO)
31
+ self._logger.propagate = True # Ensure it bubbles up to root by default
32
+ # Check if root logger has a SocketHandler (configured by setup_logging in scripts)
33
+ # but better yet, let's look for any SocketHandler in the hierarchy
34
+ self._check_external_logging()
35
+
36
+ def _check_external_logging(self):
37
+ """Checks if a SocketHandler is already configured in the logging hierarchy."""
38
+ curr = self._logger
39
+ while curr:
40
+ for h in curr.handlers:
41
+ if isinstance(h, SocketHandler):
42
+ return True
43
+ if not curr.propagate:
44
+ break
45
+ curr = curr.parent
46
+ return False
47
+
48
+ def configure_logging(self, host, port, name=None):
49
+ """
50
+ Configures the client to send logs to a logging server.
51
+
52
+ Args:
53
+ host (str): The host of the logging server.
54
+ port (int): The port of the logging server.
55
+ name (str, optional): A custom name for the logger.
56
+ """
57
+ if name:
58
+ self._logger = logging.getLogger(name)
59
+ self._logger.setLevel(logging.INFO)
60
+ # Ensure propagation is True so it reaches root logger if configured there
61
+ self._logger.propagate = True
62
+
63
+ # Check if already connected to this host/port via hierarchy
64
+ if self._check_external_logging():
65
+ self._logger.info(f"Using existing IPC logging configuration for {self._logger.name}")
66
+ return
67
+
68
+ # Remove existing SocketHandlers ONLY on this specific logger to avoid duplicates
69
+ # but keep others if they were there (though unlikely on this specific logger)
70
+ for h in self._logger.handlers[:]:
71
+ if isinstance(h, SocketHandler):
72
+ self._logger.removeHandler(h)
73
+
74
+ handler = SocketHandler(host, port)
75
+ self._logger.addHandler(handler)
76
+ self._logger.info(f"Logging configured for {self._logger.name} to {host}:{port}")
77
+
78
+ @staticmethod
79
+ def discover_servers(timeout=2):
80
+ """
81
+ Discovers game servers on the local network using UDP multicast.
82
+
83
+ Args:
84
+ timeout (int): The number of seconds to listen for responses.
85
+
86
+ Returns:
87
+ A list of (host, port) tuples for discovered servers.
88
+ """
89
+ servers = []
90
+ with socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) as sock:
91
+ sock.settimeout(timeout)
92
+
93
+ try:
94
+ sock.sendto(DISCOVERY_MESSAGE, (MULTICAST_GROUP, DISCOVERY_PORT))
95
+ except OSError:
96
+ # On some systems (like MacOS in CI), multicast might not be available
97
+ return []
98
+
99
+ end_time = time.time() + timeout
100
+ while time.time() < end_time:
101
+ try:
102
+ data, _ = sock.recvfrom(1024)
103
+ ip_bytes, port = struct.unpack(RESPONSE_MESSAGE_FORMAT, data)
104
+ host = ip_bytes.decode('utf-8').strip('\x00')
105
+ servers.append((host, port))
106
+ except socket.timeout:
107
+ break
108
+ except Exception:
109
+ continue
110
+
111
+ return list(set(servers))
112
+
113
+ def _send_command(self, action, params=None, timeout=5):
114
+ """Sends a command to the server and returns the response."""
115
+ self._logger.debug(f"Sending command {action} with params {params}")
116
+ try:
117
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
118
+ sock.settimeout(timeout)
119
+
120
+ conn = None
121
+ if self.use_tls:
122
+ context = ssl.create_default_context()
123
+ context.check_hostname = False
124
+ context.verify_mode = ssl.CERT_NONE # Accept self-signed cert
125
+ conn = context.wrap_socket(sock, server_hostname=self.host)
126
+ else:
127
+ conn = sock
128
+
129
+ with conn:
130
+ conn.connect((self.host, self.port))
131
+ command = {
132
+ 'action': action,
133
+ 'params': params or {},
134
+ 'password': self.password,
135
+ }
136
+ conn.sendall(json.dumps(command).encode('utf-8'))
137
+
138
+ response_data = conn.recv(1024)
139
+ if not response_data:
140
+ raise exceptions.ConnectionError("Server closed the connection without a response (possible TLS mismatch).")
141
+
142
+ response = json.loads(response_data.decode('utf-8'))
143
+
144
+ if response.get('status') == 'error':
145
+ self._handle_error(response)
146
+
147
+ # If there's data, return it; otherwise return the response itself
148
+ # to allow checking 'status' or other fields for commands without 'data'.
149
+ if 'data' in response:
150
+ return response.get('data')
151
+ return response
152
+ except (socket.error, ssl.SSLError) as e:
153
+ raise exceptions.ConnectionError(f"Failed to connect to server: {e}")
154
+ except json.JSONDecodeError:
155
+ raise exceptions.ConnectionError("Failed to decode server response (possible TLS mismatch).")
156
+
157
+ def _handle_error(self, response):
158
+ """Raises the appropriate client-side exception based on the server's response."""
159
+ error_type = response.get('type', 'ServerError')
160
+ message = response.get('message', 'An unknown error occurred.')
161
+
162
+ exception_class = getattr(exceptions, error_type, exceptions.ServerError)
163
+ raise exception_class(message)
164
+
165
+ def create_game(self, group_id=None, **game_options):
166
+ """Requests the server to create a new game and returns a proxy to it."""
167
+ params = game_options.copy()
168
+ if group_id:
169
+ params['group_id'] = group_id
170
+ data = self._send_command('create_game', params)
171
+ remote_game = RemoteGame(data['game_id'], self.host, self.port, self.password, self.use_tls)
172
+
173
+ # Propagate logging configuration if any
174
+ for h in self._logger.handlers:
175
+ if isinstance(h, SocketHandler):
176
+ remote_game.configure_logging(h.host, h.port)
177
+ break
178
+
179
+ return remote_game
180
+
181
+ def list_games(self):
182
+ """Retrieves a dictionary of active games as RemoteGame objects, indexed by ID."""
183
+ games_data = self._send_command('list_games')
184
+
185
+ remote_games = {}
186
+ for gid in games_data:
187
+ remote_games[gid] = RemoteGame(gid, self.host, self.port, self.password, self.use_tls)
188
+
189
+ # Propagate logging configuration if any
190
+ for h in self._logger.handlers:
191
+ if isinstance(h, SocketHandler):
192
+ remote_games[gid].configure_logging(h.host, h.port)
193
+ break
194
+
195
+ return remote_games
196
+
197
+ def create_group(self, name, admin_password=None, **attributes):
198
+ """Requests the server to create a new game group and returns a proxy to it."""
199
+ data = self._send_command('create_group', {'name': name, 'admin_password': admin_password, 'attributes': attributes})
200
+ remote_group = RemoteGroup(data['group_id'], self.host, self.port, self.password, self.use_tls)
201
+
202
+ # Propagate logging configuration if any
203
+ for h in self._logger.handlers:
204
+ if isinstance(h, SocketHandler):
205
+ remote_group.configure_logging(h.host, h.port)
206
+ break
207
+
208
+ return remote_group
209
+
210
+ def list_groups(self):
211
+ """Retrieves a dictionary of game groups as RemoteGroup objects, indexed by ID."""
212
+ groups_data = self._send_command('list_groups')
213
+
214
+ remote_groups = {}
215
+ for gid in groups_data:
216
+ remote_groups[gid] = RemoteGroup(gid, self.host, self.port, self.password, self.use_tls)
217
+
218
+ # Propagate logging configuration if any
219
+ for h in self._logger.handlers:
220
+ if isinstance(h, SocketHandler):
221
+ remote_groups[gid].configure_logging(h.host, h.port)
222
+ break
223
+
224
+ return remote_groups
225
+
226
+ class ServerAdmin:
227
+ """
228
+ A client class for administrators to connect to and manage a GameServer.
229
+ """
230
+ def __init__(self, host='127.0.0.1', port=65432, admin_password=None, use_tls=False):
231
+ self.host = host
232
+ self.port = port
233
+ self.admin_password = admin_password
234
+ self.use_tls = use_tls
235
+ self._client = GameClient(host, port, admin_password, use_tls)
236
+ self._logger = logging.getLogger("ServerAdmin")
237
+ self._logger.setLevel(logging.INFO)
238
+
239
+ def configure_logging(self, host, port):
240
+ """Configures the admin client to send logs to a logging server."""
241
+ self._client.configure_logging(host, port, "ServerAdmin")
242
+ self._logger = self._client._logger
243
+
244
+ def stop_server(self):
245
+ """Requests the server to shut down."""
246
+ return self._client._send_command('stop_server')
247
+
248
+ def restart_server(self):
249
+ """Requests the server to restart (clears all current games)."""
250
+ return self._client._send_command('restart_server')
251
+
252
+ def get_server_info(self):
253
+ """Retrieves information about the server's status and active games."""
254
+ return self._client._send_command('get_server_info')
255
+
256
+ def list_games(self):
257
+ """Retrieves a list of available games from the server."""
258
+ return self._client.list_games()
259
+
260
+ def kick_player(self, game_id, player_id):
261
+ """Kicks a player from a specific game."""
262
+ return self._client._send_command('kick_player', {'game_id': game_id, 'player_id': player_id})
263
+
264
+ def kick_observer(self, game_id, observer_id):
265
+ """Kicks an observer from a specific game."""
266
+ return self._client._send_command('kick_observer', {'game_id': game_id, 'observer_id': observer_id})
267
+
268
+ def list_all_players(self):
269
+ """Lists all players currently connected to the server across all games."""
270
+ return self._client._send_command('list_all_players')
271
+
272
+ def set_logging_config(self, host, port):
273
+ """Sets the logging server address and port."""
274
+ return self._client._send_command('set_logging_config', {'host': host, 'port': port})
275
+
276
+ def set_logging_enabled(self, enabled):
277
+ """Enables or disables logging on the server."""
278
+ return self._client._send_command('set_logging_enabled', {'enabled': enabled})
279
+
280
+ def get_cert_expiration(self):
281
+ """Returns the expiration date of the server's TLS certificate."""
282
+ response = self._client._send_command('get_cert_expiration')
283
+ return response.get('expiration')
284
+
285
+ def set_server_password(self, new_password):
286
+ """Sets a new password for the server."""
287
+ return self._client._send_command('set_server_password', {'new_password': new_password})
288
+
289
+ def set_admin_password(self, new_password):
290
+ """Sets a new administrator password for the server."""
291
+ result = self._client._send_command('set_admin_password', {'new_password': new_password})
292
+ if result.get('status') == 'success':
293
+ self.admin_password = new_password
294
+ self._client.password = new_password
295
+ return result
296
+
297
+ def create_group(self, name, admin_password=None, **attributes):
298
+ """Creates a new game group on the server."""
299
+ return self._client.create_group(name, admin_password, **attributes)
300
+
301
+ def remove_group(self, group_id):
302
+ """Removes a game group from the server by its ID."""
303
+ return self._client._send_command('remove_group', {'group_id': group_id})
304
+
305
+ def list_groups(self):
306
+ """Retrieves a list of all game groups on the server as RemoteGroup objects."""
307
+ return self._client.list_groups()
308
+
309
+ class GroupAdmin:
310
+ """
311
+ A client class for group administrators to manage games within a specific GameGroup.
312
+ """
313
+ def __init__(self, group_id, host='127.0.0.1', port=65432, group_admin_password=None, use_tls=False):
314
+ self.group_id = group_id
315
+ self.host = host
316
+ self.port = port
317
+ self.group_admin_password = group_admin_password
318
+ self.use_tls = use_tls
319
+ self._client = GameClient(host, port, group_admin_password, use_tls)
320
+ self._logger = logging.getLogger(f"GroupAdmin.{group_id}")
321
+ self._logger.setLevel(logging.INFO)
322
+
323
+ def configure_logging(self, host, port):
324
+ """Configures the group admin client to send logs to a logging server."""
325
+ self._client.configure_logging(host, port, f"GroupAdmin.{self.group_id}")
326
+ self._logger = self._client._logger
327
+
328
+ def list_games(self):
329
+ """Retrieves a dictionary of games belonging to this group as RemoteGame objects, indexed by ID."""
330
+ remote_group = RemoteGroup(self.group_id, self.host, self.port, self.group_admin_password, self.use_tls)
331
+
332
+ # Propagate logging configuration if any
333
+ for h in self._logger.handlers:
334
+ if isinstance(h, SocketHandler):
335
+ remote_group.configure_logging(h.host, h.port)
336
+ break
337
+
338
+ return remote_group.list_games()
339
+
340
+ def kick_player(self, game_id, player_id):
341
+ """Kicks a player from a specific game in the group."""
342
+ return self._client._send_command('kick_player', {
343
+ 'game_id': game_id,
344
+ 'player_id': player_id,
345
+ 'group_id': self.group_id
346
+ })
347
+
348
+ def kick_observer(self, game_id, observer_id):
349
+ """Kicks an observer from a specific game in the group."""
350
+ return self._client._send_command('kick_observer', {
351
+ 'game_id': game_id,
352
+ 'observer_id': observer_id,
353
+ 'group_id': self.group_id
354
+ })
355
+
356
+ def set_group_admin_password(self, new_password):
357
+ """Sets a new administrator password for this group."""
358
+ result = self._client._send_command('set_group_admin_password', {
359
+ 'group_id': self.group_id,
360
+ 'new_password': new_password
361
+ })
362
+ if result.get('status') == 'success':
363
+ self.group_admin_password = new_password
364
+ self._client.password = new_password
365
+ return result
366
+
367
+ class RemoteGame:
368
+ """
369
+ A proxy for a Game object on a remote server.
370
+ """
371
+ def __init__(self, game_id, host='127.0.0.1', port=65432, password=None, use_tls=False):
372
+ self.game_id = game_id
373
+ self.host = host
374
+ self.port = port
375
+ self._client = GameClient(host, port, password, use_tls)
376
+ self._logger = logging.getLogger("RemoteGame")
377
+ self._logger.setLevel(logging.INFO)
378
+ self._logger.propagate = True
379
+ # RemoteGame should ideally use the same logger name if possible
380
+ # but for now we just ensure it propagates to the same destination.
381
+
382
+ def configure_logging(self, host, port, name=None):
383
+ """Configures the remote game proxy to send logs to a logging server."""
384
+ if name is None:
385
+ name = f"RemoteGame.{self.game_id[:8]}"
386
+ self._client.configure_logging(host, port, name)
387
+ self._logger = self._client._logger
388
+
389
+ def _send_command(self, action, params=None):
390
+ """Sends a command to the server for a specific game and returns the response."""
391
+ full_params = {'game_id': self.game_id}
392
+ if params:
393
+ full_params.update(params)
394
+ return self._client._send_command(action, full_params)
395
+
396
+ def add_player(self, player, password=None):
397
+ """
398
+ Adds a player to the remote game.
399
+
400
+ Args:
401
+ player (Player): The player to add.
402
+ password (str, optional): The password for this specific game.
403
+ """
404
+ self._logger.info(f"Adding player {player.name} to game {self.game_id}")
405
+ params = {
406
+ 'player': {'name': player.name, 'attributes': player.attributes},
407
+ 'game_password': password,
408
+ }
409
+ self._send_command('add_player', params)
410
+
411
+ def add_observer(self, observer, password=None):
412
+ """
413
+ Adds an observer to the remote game.
414
+
415
+ Args:
416
+ observer (Observer): The observer to add.
417
+ password (str, optional): The password for observers of this game.
418
+ """
419
+ params = {
420
+ 'observer': {'name': observer.name, 'attributes': observer.attributes},
421
+ 'observer_password': password,
422
+ }
423
+ self._send_command('add_observer', params)
424
+
425
+ def start(self):
426
+ """Starts the remote game."""
427
+ self._logger.info(f"Starting game {self.game_id}")
428
+ self._send_command('start')
429
+
430
+ def pause(self):
431
+ """Pauses the remote game."""
432
+ self._send_command('pause')
433
+
434
+ def resume(self):
435
+ """Resumes the remote game."""
436
+ self._send_command('resume')
437
+
438
+ def stop(self):
439
+ """Stops the remote game."""
440
+ self._send_command('stop')
441
+
442
+ def next_turn(self):
443
+ """Advances to the next turn in the remote game."""
444
+ self._logger.debug(f"Advancing turn in game {self.game_id}")
445
+ self._send_command('next_turn')
446
+
447
+ @property
448
+ def current_player(self):
449
+ """Gets the current player from the remote game."""
450
+ data = self._send_command('get_current_player')
451
+ if data:
452
+ return Player(data['name'], **data['attributes'])
453
+ return None
454
+
455
+ @property
456
+ def state(self):
457
+ """Gets the state of the remote game."""
458
+ return self._send_command('get_game_state')
459
+
460
+ @property
461
+ def players(self):
462
+ """Gets the list of players in the remote game."""
463
+ data = self._send_command('get_players')
464
+ return [Player(p['name'], id=p['id'], **p['attributes']) for p in data]
465
+
466
+ @property
467
+ def observers(self):
468
+ """Gets the list of observers in the remote game."""
469
+ data = self._send_command('get_observers')
470
+ return [Observer(o['name'], id=o['id'], **o['attributes']) for o in data]
471
+
472
+ def set_state(self, state):
473
+ """Sets the state of the remote game."""
474
+ return self._send_command('set_game_state', {'state': state})
475
+
476
+ class RemoteGroup:
477
+ """
478
+ A proxy for a GameGroup object on a remote server.
479
+ """
480
+ def __init__(self, group_id, host='127.0.0.1', port=65432, password=None, use_tls=False):
481
+ self.group_id = group_id
482
+ self.host = host
483
+ self.port = port
484
+ self._client = GameClient(host, port, password, use_tls)
485
+ self._logger = logging.getLogger(f"RemoteGroup.{group_id}")
486
+ self._logger.setLevel(logging.INFO)
487
+ self._logger.propagate = True
488
+
489
+ def configure_logging(self, host, port, name=None):
490
+ """Configures the remote group proxy to send logs to a logging server."""
491
+ if name is None:
492
+ name = f"RemoteGroup.{self.group_id[:8]}"
493
+ self._client.configure_logging(host, port, name)
494
+ self._logger = self._client._logger
495
+
496
+ def _send_command(self, action, params=None):
497
+ """Sends a command to the server for a specific group and returns the response."""
498
+ full_params = {'group_id': self.group_id}
499
+ if params:
500
+ full_params.update(params)
501
+ return self._client._send_command(action, full_params)
502
+
503
+ def create_game(self, **game_options):
504
+ """Creates a new game within this group."""
505
+ return self._client.create_game(group_id=self.group_id, **game_options)
506
+
507
+ def list_games(self):
508
+ """Lists all games in this group."""
509
+ games_data = self._client._send_command('list_group_games', {'group_id': self.group_id})
510
+
511
+ remote_games = {}
512
+ for gid in games_data:
513
+ remote_games[gid] = RemoteGame(gid, self.host, self.port, self._client.password, self._client.use_tls)
514
+
515
+ # Propagate logging configuration if any
516
+ for h in self._logger.handlers:
517
+ if isinstance(h, SocketHandler):
518
+ remote_games[gid].configure_logging(h.host, h.port)
519
+ break
520
+
521
+ return remote_games
522
+
523
+ @property
524
+ def name(self):
525
+ """Gets the name of the group."""
526
+ return self._send_command('get_group_info').get('name')
527
+
528
+ @property
529
+ def attributes(self):
530
+ """Gets the attributes of the group."""
531
+ return self._send_command('get_group_info').get('attributes', {})
@@ -0,0 +1 @@
1
+ """Data resources for multiplayer package."""
@@ -0,0 +1,103 @@
1
+ city
2
+ Tokyo
3
+ Delhi
4
+ Shanghai
5
+ Sao Paulo
6
+ Mumbai
7
+ Mexico City
8
+ Beijing
9
+ Osaka
10
+ Cairo
11
+ New York
12
+ Dhaka
13
+ Karachi
14
+ Buenos Aires
15
+ Kolkata
16
+ Istanbul
17
+ Chongqing
18
+ Lagos
19
+ Manila
20
+ Rio de Janeiro
21
+ Tianjin
22
+ Kinshasa
23
+ Guangzhou
24
+ Los Angeles
25
+ Moscow
26
+ Shenzhen
27
+ Lahore
28
+ Bangalore
29
+ Paris
30
+ Bogota
31
+ Jakarta
32
+ Chennai
33
+ Lima
34
+ Bangkok
35
+ Seoul
36
+ Nagoya
37
+ Hyderabad
38
+ London
39
+ Tehran
40
+ Chicago
41
+ Chengdu
42
+ Nanjing
43
+ Wuhan
44
+ Ho Chi Minh City
45
+ Luanda
46
+ Ahmedabad
47
+ Kuala Lumpur
48
+ Xian
49
+ Hong Kong
50
+ Dongguan
51
+ Hangzhou
52
+ Foshan
53
+ Shenyang
54
+ Riyadh
55
+ Baghdad
56
+ Santiago
57
+ Surat
58
+ Madrid
59
+ Suzhou
60
+ Pune
61
+ Harbin
62
+ Houston
63
+ Dallas
64
+ Toronto
65
+ Dar es Salaam
66
+ Miami
67
+ Belo Horizonte
68
+ Singapore
69
+ Philadelphia
70
+ Atlanta
71
+ Fukuoka
72
+ Khartoum
73
+ Barcelona
74
+ Johannesburg
75
+ Saint Petersburg
76
+ Qingdao
77
+ Dalian
78
+ Washington
79
+ Yangon
80
+ Alexandria
81
+ Jinan
82
+ Guadalajara
83
+ Ankara
84
+ Melbourne
85
+ Abidjan
86
+ Sydney
87
+ Monterrey
88
+ Changsha
89
+ Brasilia
90
+ Cape Town
91
+ Urumqi
92
+ Jeddah
93
+ Recife
94
+ Hanoi
95
+ Medellin
96
+ Zhengzhou
97
+ Fortaleza
98
+ Prague
99
+ Nairobi
100
+ Rome
101
+ Casablanca
102
+ Kunming
103
+ Salvador