multilinkk 0.1.0__tar.gz
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.
- multilinkk-0.1.0/LICENSE +21 -0
- multilinkk-0.1.0/PKG-INFO +231 -0
- multilinkk-0.1.0/README.md +203 -0
- multilinkk-0.1.0/multilink/__init__.py +38 -0
- multilinkk-0.1.0/multilink/client.py +221 -0
- multilinkk-0.1.0/multilink/player.py +79 -0
- multilinkk-0.1.0/multilink/server.py +249 -0
- multilinkk-0.1.0/multilink/sync.py +133 -0
- multilinkk-0.1.0/multilink/transport/__init__.py +3 -0
- multilinkk-0.1.0/multilink/transport/websocket.py +113 -0
- multilinkk-0.1.0/multilinkk.egg-info/PKG-INFO +231 -0
- multilinkk-0.1.0/multilinkk.egg-info/SOURCES.txt +16 -0
- multilinkk-0.1.0/multilinkk.egg-info/dependency_links.txt +1 -0
- multilinkk-0.1.0/multilinkk.egg-info/requires.txt +1 -0
- multilinkk-0.1.0/multilinkk.egg-info/top_level.txt +1 -0
- multilinkk-0.1.0/pyproject.toml +37 -0
- multilinkk-0.1.0/setup.cfg +4 -0
- multilinkk-0.1.0/tests/test_multilink.py +140 -0
multilinkk-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 Marcus
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: multilinkk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A high-level Python multiplayer library. Just works.
|
|
5
|
+
Author: Marcus
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/yourusername/multilink
|
|
8
|
+
Project-URL: Repository, https://github.com/yourusername/multilink
|
|
9
|
+
Project-URL: Issues, https://github.com/yourusername/multilink/issues
|
|
10
|
+
Keywords: multiplayer,networking,websocket,gamedev,realtime
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Topic :: Games/Entertainment
|
|
21
|
+
Classifier: Topic :: Internet
|
|
22
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
23
|
+
Requires-Python: >=3.8
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
License-File: LICENSE
|
|
26
|
+
Requires-Dist: websockets>=12.0
|
|
27
|
+
Dynamic: license-file
|
|
28
|
+
|
|
29
|
+
# multilink
|
|
30
|
+
|
|
31
|
+
**A high-level Python multiplayer library. Just works.**
|
|
32
|
+
|
|
33
|
+
No presets. No opinions. You define the events, the data, and the behavior — multilink handles the connections, state sync, and networking underneath.
|
|
34
|
+
|
|
35
|
+
```
|
|
36
|
+
pip install multilink
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
## Quickstart
|
|
42
|
+
|
|
43
|
+
**Server:**
|
|
44
|
+
```python
|
|
45
|
+
from multilink import Server
|
|
46
|
+
|
|
47
|
+
server = Server(port=5000)
|
|
48
|
+
|
|
49
|
+
@server.on("connect")
|
|
50
|
+
def on_connect(player):
|
|
51
|
+
server.broadcast("joined", {"id": player.id})
|
|
52
|
+
|
|
53
|
+
@server.on("move")
|
|
54
|
+
def on_move(player, data):
|
|
55
|
+
player.update_state({"x": data["x"], "y": data["y"]})
|
|
56
|
+
|
|
57
|
+
@server.on("disconnect")
|
|
58
|
+
def on_disconnect(player):
|
|
59
|
+
server.broadcast("left", {"id": player.id})
|
|
60
|
+
|
|
61
|
+
@server.on_error()
|
|
62
|
+
def on_error(error, player):
|
|
63
|
+
print(f"Error from {player.id}: {error}")
|
|
64
|
+
|
|
65
|
+
server.start()
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
**Client:**
|
|
69
|
+
```python
|
|
70
|
+
from multilink import Client
|
|
71
|
+
|
|
72
|
+
client = Client("localhost", 5000)
|
|
73
|
+
|
|
74
|
+
@client.on("connect")
|
|
75
|
+
def on_connect():
|
|
76
|
+
client.send("move", {"x": 10, "y": 20})
|
|
77
|
+
|
|
78
|
+
@client.on("joined")
|
|
79
|
+
def on_joined(data):
|
|
80
|
+
print(f"Player {data['id']} joined!")
|
|
81
|
+
|
|
82
|
+
@client.on("__state_sync__")
|
|
83
|
+
def on_sync(data):
|
|
84
|
+
for player_id, state in data["players"].items():
|
|
85
|
+
print(f"{player_id}: {state}")
|
|
86
|
+
|
|
87
|
+
@client.on_error()
|
|
88
|
+
def on_error(error):
|
|
89
|
+
print(f"Error: {error}")
|
|
90
|
+
|
|
91
|
+
client.connect()
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
---
|
|
95
|
+
|
|
96
|
+
## Core concepts
|
|
97
|
+
|
|
98
|
+
### Events
|
|
99
|
+
Everything is event-based. Send an event from the client, handle it on the server (and vice versa) with a simple decorator.
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
# Client sends:
|
|
103
|
+
client.send("chat", {"msg": "hello!"})
|
|
104
|
+
|
|
105
|
+
# Server handles:
|
|
106
|
+
@server.on("chat")
|
|
107
|
+
def on_chat(player, data):
|
|
108
|
+
server.broadcast("chat", {"from": player.id, "msg": data["msg"]})
|
|
109
|
+
|
|
110
|
+
# All clients receive:
|
|
111
|
+
@client.on("chat")
|
|
112
|
+
def on_chat(data):
|
|
113
|
+
print(f"[{data['from']}]: {data['msg']}")
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
### Player state sync
|
|
117
|
+
Each player has a `.state` dict. Update it on the server and multilink automatically broadcasts only the changed fields to all clients at 20 ticks/sec.
|
|
118
|
+
|
|
119
|
+
```python
|
|
120
|
+
@server.on("move")
|
|
121
|
+
def on_move(player, data):
|
|
122
|
+
player.update_state({"x": data["x"], "y": data["y"]})
|
|
123
|
+
# That's it — state is synced automatically
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Force an immediate sync if you can't wait for the next tick:
|
|
127
|
+
```python
|
|
128
|
+
server.sync_state() # delta only
|
|
129
|
+
server.sync_state(full=True) # full state
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
### Sending to one player
|
|
133
|
+
```python
|
|
134
|
+
@server.on("connect")
|
|
135
|
+
def on_connect(player):
|
|
136
|
+
# Send something only to this player
|
|
137
|
+
asyncio.ensure_future(player.send("welcome", {"id": player.id}))
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
### Error handling
|
|
141
|
+
Errors in event handlers are caught and routed to your error handler instead of crashing everything.
|
|
142
|
+
|
|
143
|
+
```python
|
|
144
|
+
@server.on_error()
|
|
145
|
+
def on_error(error, player):
|
|
146
|
+
print(f"Error from {player.id}: {error}")
|
|
147
|
+
|
|
148
|
+
@client.on_error()
|
|
149
|
+
def on_error(error):
|
|
150
|
+
print(f"Client error: {error}")
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
### Send queue
|
|
154
|
+
Messages sent before the client is fully connected are automatically queued and delivered the moment the connection opens. No timing issues.
|
|
155
|
+
|
|
156
|
+
```python
|
|
157
|
+
client.send("hello", {"msg": "this works even before connect() is called"})
|
|
158
|
+
client.connect() # queued message is flushed on connect
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
---
|
|
162
|
+
|
|
163
|
+
## API Reference
|
|
164
|
+
|
|
165
|
+
### `Server(host, port, tick_rate, delta_only)`
|
|
166
|
+
|
|
167
|
+
| Arg | Default | Description |
|
|
168
|
+
|-----|---------|-------------|
|
|
169
|
+
| `host` | `"0.0.0.0"` | Hostname to bind to |
|
|
170
|
+
| `port` | `5000` | Port to listen on |
|
|
171
|
+
| `tick_rate` | `20` | State syncs per second |
|
|
172
|
+
| `delta_only` | `True` | Only broadcast changed fields |
|
|
173
|
+
|
|
174
|
+
| Method | Description |
|
|
175
|
+
|--------|-------------|
|
|
176
|
+
| `server.on(event)` | Register an event handler |
|
|
177
|
+
| `server.on_error()` | Register an error handler |
|
|
178
|
+
| `server.broadcast(event, data)` | Send event to all players |
|
|
179
|
+
| `server.sync_state(full=False)` | Force immediate state sync |
|
|
180
|
+
| `server.get_players()` | List of all connected Players |
|
|
181
|
+
| `server.get_player(id)` | Look up a player by ID |
|
|
182
|
+
| `server.player_count` | Number of connected players |
|
|
183
|
+
| `server.start()` | Start the server (blocking) |
|
|
184
|
+
|
|
185
|
+
### `Client(host, port)`
|
|
186
|
+
|
|
187
|
+
| Method | Description |
|
|
188
|
+
|--------|-------------|
|
|
189
|
+
| `client.on(event)` | Register an event handler |
|
|
190
|
+
| `client.on_error()` | Register an error handler |
|
|
191
|
+
| `client.send(event, data)` | Send event to server |
|
|
192
|
+
| `client.connect()` | Connect and start listening (blocking) |
|
|
193
|
+
|
|
194
|
+
### `Player`
|
|
195
|
+
|
|
196
|
+
| Attribute/Method | Description |
|
|
197
|
+
|-----------------|-------------|
|
|
198
|
+
| `player.id` | Unique player ID |
|
|
199
|
+
| `player.state` | Dict of synced values |
|
|
200
|
+
| `player.set_state(key, value)` | Set one state value |
|
|
201
|
+
| `player.get_state(key, default)` | Get one state value |
|
|
202
|
+
| `player.update_state(dict)` | Merge dict into state |
|
|
203
|
+
| `player.clear_state()` | Wipe state entirely |
|
|
204
|
+
| `player.send(event, data)` | Send directly to this player |
|
|
205
|
+
| `player.connected_at` | Unix timestamp of connection time |
|
|
206
|
+
| `player.last_seen` | Unix timestamp of last message |
|
|
207
|
+
|
|
208
|
+
---
|
|
209
|
+
|
|
210
|
+
## Built-in events
|
|
211
|
+
|
|
212
|
+
| Event | Where | Handler signature |
|
|
213
|
+
|-------|-------|------------------|
|
|
214
|
+
| `"connect"` | server | `fn(player)` |
|
|
215
|
+
| `"disconnect"` | server | `fn(player)` |
|
|
216
|
+
| `"connect"` | client | `fn()` |
|
|
217
|
+
| `"disconnect"` | client | `fn()` |
|
|
218
|
+
| `"__state_sync__"` | client | `fn(data)` — `data["players"]` is a dict of `player_id -> state_delta` |
|
|
219
|
+
|
|
220
|
+
---
|
|
221
|
+
|
|
222
|
+
## Requirements
|
|
223
|
+
|
|
224
|
+
- Python 3.8+
|
|
225
|
+
- `websockets >= 12.0`
|
|
226
|
+
|
|
227
|
+
---
|
|
228
|
+
|
|
229
|
+
## License
|
|
230
|
+
|
|
231
|
+
MIT
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
# multilink
|
|
2
|
+
|
|
3
|
+
**A high-level Python multiplayer library. Just works.**
|
|
4
|
+
|
|
5
|
+
No presets. No opinions. You define the events, the data, and the behavior — multilink handles the connections, state sync, and networking underneath.
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
pip install multilink
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## Quickstart
|
|
14
|
+
|
|
15
|
+
**Server:**
|
|
16
|
+
```python
|
|
17
|
+
from multilink import Server
|
|
18
|
+
|
|
19
|
+
server = Server(port=5000)
|
|
20
|
+
|
|
21
|
+
@server.on("connect")
|
|
22
|
+
def on_connect(player):
|
|
23
|
+
server.broadcast("joined", {"id": player.id})
|
|
24
|
+
|
|
25
|
+
@server.on("move")
|
|
26
|
+
def on_move(player, data):
|
|
27
|
+
player.update_state({"x": data["x"], "y": data["y"]})
|
|
28
|
+
|
|
29
|
+
@server.on("disconnect")
|
|
30
|
+
def on_disconnect(player):
|
|
31
|
+
server.broadcast("left", {"id": player.id})
|
|
32
|
+
|
|
33
|
+
@server.on_error()
|
|
34
|
+
def on_error(error, player):
|
|
35
|
+
print(f"Error from {player.id}: {error}")
|
|
36
|
+
|
|
37
|
+
server.start()
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
**Client:**
|
|
41
|
+
```python
|
|
42
|
+
from multilink import Client
|
|
43
|
+
|
|
44
|
+
client = Client("localhost", 5000)
|
|
45
|
+
|
|
46
|
+
@client.on("connect")
|
|
47
|
+
def on_connect():
|
|
48
|
+
client.send("move", {"x": 10, "y": 20})
|
|
49
|
+
|
|
50
|
+
@client.on("joined")
|
|
51
|
+
def on_joined(data):
|
|
52
|
+
print(f"Player {data['id']} joined!")
|
|
53
|
+
|
|
54
|
+
@client.on("__state_sync__")
|
|
55
|
+
def on_sync(data):
|
|
56
|
+
for player_id, state in data["players"].items():
|
|
57
|
+
print(f"{player_id}: {state}")
|
|
58
|
+
|
|
59
|
+
@client.on_error()
|
|
60
|
+
def on_error(error):
|
|
61
|
+
print(f"Error: {error}")
|
|
62
|
+
|
|
63
|
+
client.connect()
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
---
|
|
67
|
+
|
|
68
|
+
## Core concepts
|
|
69
|
+
|
|
70
|
+
### Events
|
|
71
|
+
Everything is event-based. Send an event from the client, handle it on the server (and vice versa) with a simple decorator.
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
# Client sends:
|
|
75
|
+
client.send("chat", {"msg": "hello!"})
|
|
76
|
+
|
|
77
|
+
# Server handles:
|
|
78
|
+
@server.on("chat")
|
|
79
|
+
def on_chat(player, data):
|
|
80
|
+
server.broadcast("chat", {"from": player.id, "msg": data["msg"]})
|
|
81
|
+
|
|
82
|
+
# All clients receive:
|
|
83
|
+
@client.on("chat")
|
|
84
|
+
def on_chat(data):
|
|
85
|
+
print(f"[{data['from']}]: {data['msg']}")
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### Player state sync
|
|
89
|
+
Each player has a `.state` dict. Update it on the server and multilink automatically broadcasts only the changed fields to all clients at 20 ticks/sec.
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
@server.on("move")
|
|
93
|
+
def on_move(player, data):
|
|
94
|
+
player.update_state({"x": data["x"], "y": data["y"]})
|
|
95
|
+
# That's it — state is synced automatically
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Force an immediate sync if you can't wait for the next tick:
|
|
99
|
+
```python
|
|
100
|
+
server.sync_state() # delta only
|
|
101
|
+
server.sync_state(full=True) # full state
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### Sending to one player
|
|
105
|
+
```python
|
|
106
|
+
@server.on("connect")
|
|
107
|
+
def on_connect(player):
|
|
108
|
+
# Send something only to this player
|
|
109
|
+
asyncio.ensure_future(player.send("welcome", {"id": player.id}))
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
### Error handling
|
|
113
|
+
Errors in event handlers are caught and routed to your error handler instead of crashing everything.
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
@server.on_error()
|
|
117
|
+
def on_error(error, player):
|
|
118
|
+
print(f"Error from {player.id}: {error}")
|
|
119
|
+
|
|
120
|
+
@client.on_error()
|
|
121
|
+
def on_error(error):
|
|
122
|
+
print(f"Client error: {error}")
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
### Send queue
|
|
126
|
+
Messages sent before the client is fully connected are automatically queued and delivered the moment the connection opens. No timing issues.
|
|
127
|
+
|
|
128
|
+
```python
|
|
129
|
+
client.send("hello", {"msg": "this works even before connect() is called"})
|
|
130
|
+
client.connect() # queued message is flushed on connect
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
---
|
|
134
|
+
|
|
135
|
+
## API Reference
|
|
136
|
+
|
|
137
|
+
### `Server(host, port, tick_rate, delta_only)`
|
|
138
|
+
|
|
139
|
+
| Arg | Default | Description |
|
|
140
|
+
|-----|---------|-------------|
|
|
141
|
+
| `host` | `"0.0.0.0"` | Hostname to bind to |
|
|
142
|
+
| `port` | `5000` | Port to listen on |
|
|
143
|
+
| `tick_rate` | `20` | State syncs per second |
|
|
144
|
+
| `delta_only` | `True` | Only broadcast changed fields |
|
|
145
|
+
|
|
146
|
+
| Method | Description |
|
|
147
|
+
|--------|-------------|
|
|
148
|
+
| `server.on(event)` | Register an event handler |
|
|
149
|
+
| `server.on_error()` | Register an error handler |
|
|
150
|
+
| `server.broadcast(event, data)` | Send event to all players |
|
|
151
|
+
| `server.sync_state(full=False)` | Force immediate state sync |
|
|
152
|
+
| `server.get_players()` | List of all connected Players |
|
|
153
|
+
| `server.get_player(id)` | Look up a player by ID |
|
|
154
|
+
| `server.player_count` | Number of connected players |
|
|
155
|
+
| `server.start()` | Start the server (blocking) |
|
|
156
|
+
|
|
157
|
+
### `Client(host, port)`
|
|
158
|
+
|
|
159
|
+
| Method | Description |
|
|
160
|
+
|--------|-------------|
|
|
161
|
+
| `client.on(event)` | Register an event handler |
|
|
162
|
+
| `client.on_error()` | Register an error handler |
|
|
163
|
+
| `client.send(event, data)` | Send event to server |
|
|
164
|
+
| `client.connect()` | Connect and start listening (blocking) |
|
|
165
|
+
|
|
166
|
+
### `Player`
|
|
167
|
+
|
|
168
|
+
| Attribute/Method | Description |
|
|
169
|
+
|-----------------|-------------|
|
|
170
|
+
| `player.id` | Unique player ID |
|
|
171
|
+
| `player.state` | Dict of synced values |
|
|
172
|
+
| `player.set_state(key, value)` | Set one state value |
|
|
173
|
+
| `player.get_state(key, default)` | Get one state value |
|
|
174
|
+
| `player.update_state(dict)` | Merge dict into state |
|
|
175
|
+
| `player.clear_state()` | Wipe state entirely |
|
|
176
|
+
| `player.send(event, data)` | Send directly to this player |
|
|
177
|
+
| `player.connected_at` | Unix timestamp of connection time |
|
|
178
|
+
| `player.last_seen` | Unix timestamp of last message |
|
|
179
|
+
|
|
180
|
+
---
|
|
181
|
+
|
|
182
|
+
## Built-in events
|
|
183
|
+
|
|
184
|
+
| Event | Where | Handler signature |
|
|
185
|
+
|-------|-------|------------------|
|
|
186
|
+
| `"connect"` | server | `fn(player)` |
|
|
187
|
+
| `"disconnect"` | server | `fn(player)` |
|
|
188
|
+
| `"connect"` | client | `fn()` |
|
|
189
|
+
| `"disconnect"` | client | `fn()` |
|
|
190
|
+
| `"__state_sync__"` | client | `fn(data)` — `data["players"]` is a dict of `player_id -> state_delta` |
|
|
191
|
+
|
|
192
|
+
---
|
|
193
|
+
|
|
194
|
+
## Requirements
|
|
195
|
+
|
|
196
|
+
- Python 3.8+
|
|
197
|
+
- `websockets >= 12.0`
|
|
198
|
+
|
|
199
|
+
---
|
|
200
|
+
|
|
201
|
+
## License
|
|
202
|
+
|
|
203
|
+
MIT
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""
|
|
2
|
+
multilink
|
|
3
|
+
~~~~~~~~~
|
|
4
|
+
A high-level Python multiplayer library. Just works.
|
|
5
|
+
|
|
6
|
+
Basic usage::
|
|
7
|
+
|
|
8
|
+
from multilink import Server, Client
|
|
9
|
+
|
|
10
|
+
# Server
|
|
11
|
+
server = Server(port=5000)
|
|
12
|
+
|
|
13
|
+
@server.on("connect")
|
|
14
|
+
def on_connect(player):
|
|
15
|
+
server.broadcast("joined", {"id": player.id})
|
|
16
|
+
|
|
17
|
+
server.start()
|
|
18
|
+
|
|
19
|
+
# Client
|
|
20
|
+
client = Client("localhost", 5000)
|
|
21
|
+
|
|
22
|
+
@client.on("joined")
|
|
23
|
+
def on_joined(data):
|
|
24
|
+
print(f"Player {data['id']} joined!")
|
|
25
|
+
|
|
26
|
+
client.connect()
|
|
27
|
+
|
|
28
|
+
:copyright: 2024 Marcus
|
|
29
|
+
:license: MIT
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from .server import Server
|
|
33
|
+
from .client import Client
|
|
34
|
+
from .player import Player
|
|
35
|
+
|
|
36
|
+
__version__ = "0.1.0"
|
|
37
|
+
__author__ = "Marcus"
|
|
38
|
+
__all__ = ["Server", "Client", "Player"]
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""
|
|
2
|
+
multilink.client
|
|
3
|
+
~~~~~~~~~~~~~~~~
|
|
4
|
+
The multilink Client.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
import json
|
|
9
|
+
import logging
|
|
10
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
11
|
+
|
|
12
|
+
import websockets
|
|
13
|
+
from websockets.exceptions import ConnectionClosed
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger("multilink.client")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Client:
|
|
19
|
+
"""
|
|
20
|
+
A multilink client.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
host: server hostname (default ``"localhost"``)
|
|
24
|
+
port: server port (default ``5000``)
|
|
25
|
+
|
|
26
|
+
Example::
|
|
27
|
+
|
|
28
|
+
from multilink import Client
|
|
29
|
+
|
|
30
|
+
client = Client("localhost", 5000)
|
|
31
|
+
|
|
32
|
+
@client.on("connect")
|
|
33
|
+
def on_connect():
|
|
34
|
+
client.send("hello", {"msg": "hi!"})
|
|
35
|
+
|
|
36
|
+
@client.on("welcome")
|
|
37
|
+
def on_welcome(data):
|
|
38
|
+
print(f"Server said: {data}")
|
|
39
|
+
|
|
40
|
+
@client.on("disconnect")
|
|
41
|
+
def on_disconnect():
|
|
42
|
+
print("Lost connection.")
|
|
43
|
+
|
|
44
|
+
@client.on_error()
|
|
45
|
+
def on_error(error):
|
|
46
|
+
print(f"Error: {error}")
|
|
47
|
+
|
|
48
|
+
client.connect()
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def __init__(self, host: str = "localhost", port: int = 5000):
|
|
52
|
+
self.host = host
|
|
53
|
+
self.port = port
|
|
54
|
+
self._uri = f"ws://{host}:{port}"
|
|
55
|
+
|
|
56
|
+
self._handlers: Dict[str, List[Callable]] = {}
|
|
57
|
+
self._error_handlers: List[Callable] = []
|
|
58
|
+
|
|
59
|
+
self._ws = None
|
|
60
|
+
self._connected = False
|
|
61
|
+
self._loop: Optional[asyncio.AbstractEventLoop] = None
|
|
62
|
+
# Messages sent before the connection is ready are queued
|
|
63
|
+
# and flushed automatically once connected
|
|
64
|
+
self._send_queue: List[tuple] = []
|
|
65
|
+
|
|
66
|
+
def on(self, event: str) -> Callable:
|
|
67
|
+
"""
|
|
68
|
+
Register a handler for a server event.
|
|
69
|
+
|
|
70
|
+
Built-in events: ``"connect"``, ``"disconnect"``
|
|
71
|
+
|
|
72
|
+
Handler signatures:
|
|
73
|
+
|
|
74
|
+
- connect / disconnect: ``fn()``
|
|
75
|
+
- all others: ``fn(data)``
|
|
76
|
+
|
|
77
|
+
Example::
|
|
78
|
+
|
|
79
|
+
@client.on("game_start")
|
|
80
|
+
def on_start(data):
|
|
81
|
+
print("Game starting!")
|
|
82
|
+
"""
|
|
83
|
+
def decorator(fn: Callable) -> Callable:
|
|
84
|
+
self._handlers.setdefault(event, []).append(fn)
|
|
85
|
+
return fn
|
|
86
|
+
return decorator
|
|
87
|
+
|
|
88
|
+
def on_error(self) -> Callable:
|
|
89
|
+
"""
|
|
90
|
+
Register an error handler.
|
|
91
|
+
|
|
92
|
+
Example::
|
|
93
|
+
|
|
94
|
+
@client.on_error()
|
|
95
|
+
def on_error(error):
|
|
96
|
+
print(f"Something went wrong: {error}")
|
|
97
|
+
"""
|
|
98
|
+
def decorator(fn: Callable) -> Callable:
|
|
99
|
+
self._error_handlers.append(fn)
|
|
100
|
+
return fn
|
|
101
|
+
return decorator
|
|
102
|
+
|
|
103
|
+
def connect(self) -> None:
|
|
104
|
+
"""Connect to the server. Blocks until disconnected."""
|
|
105
|
+
try:
|
|
106
|
+
asyncio.run(self._run())
|
|
107
|
+
except KeyboardInterrupt:
|
|
108
|
+
logger.info("Client disconnected.")
|
|
109
|
+
|
|
110
|
+
async def _run(self) -> None:
|
|
111
|
+
self._loop = asyncio.get_event_loop()
|
|
112
|
+
did_connect = False
|
|
113
|
+
logger.info(f"Connecting to {self._uri}")
|
|
114
|
+
try:
|
|
115
|
+
async with websockets.connect(self._uri) as ws:
|
|
116
|
+
self._ws = ws
|
|
117
|
+
self._connected = True
|
|
118
|
+
did_connect = True
|
|
119
|
+
# Flush any messages queued before connection was ready
|
|
120
|
+
for queued_event, queued_data in self._send_queue:
|
|
121
|
+
await self._send_raw(queued_event, queued_data)
|
|
122
|
+
self._send_queue.clear()
|
|
123
|
+
await self._fire_single("connect")
|
|
124
|
+
|
|
125
|
+
async for raw in ws:
|
|
126
|
+
try:
|
|
127
|
+
message = json.loads(raw)
|
|
128
|
+
await self._handle_message(message)
|
|
129
|
+
except json.JSONDecodeError:
|
|
130
|
+
logger.warning(f"Malformed message: {raw!r}")
|
|
131
|
+
except Exception as e:
|
|
132
|
+
logger.error(f"Message error: {e}", exc_info=True)
|
|
133
|
+
await self._fire_error(e)
|
|
134
|
+
|
|
135
|
+
except ConnectionClosed:
|
|
136
|
+
logger.info("Disconnected from server")
|
|
137
|
+
except OSError as e:
|
|
138
|
+
err = ConnectionError(f"Could not connect to {self._uri}: {e}")
|
|
139
|
+
logger.error(str(err))
|
|
140
|
+
await self._fire_error(err)
|
|
141
|
+
finally:
|
|
142
|
+
self._connected = False
|
|
143
|
+
self._ws = None
|
|
144
|
+
self._loop = None
|
|
145
|
+
if did_connect:
|
|
146
|
+
await self._fire_single("disconnect")
|
|
147
|
+
|
|
148
|
+
async def _handle_message(self, message: dict) -> None:
|
|
149
|
+
event = message.get("event")
|
|
150
|
+
data = message.get("data", {})
|
|
151
|
+
if event:
|
|
152
|
+
await self._fire(event, data)
|
|
153
|
+
|
|
154
|
+
def send(self, event: str, data: Any = None) -> None:
|
|
155
|
+
"""
|
|
156
|
+
Send an event + data to the server.
|
|
157
|
+
|
|
158
|
+
Safe to call from any thread. Messages sent before the connection
|
|
159
|
+
is established are queued and delivered automatically once connected.
|
|
160
|
+
|
|
161
|
+
Example::
|
|
162
|
+
|
|
163
|
+
client.send("move", {"x": 10, "y": 20})
|
|
164
|
+
client.send("chat", {"msg": "hello!"})
|
|
165
|
+
"""
|
|
166
|
+
if not self._connected or not self._ws:
|
|
167
|
+
self._send_queue.append((event, data))
|
|
168
|
+
return
|
|
169
|
+
|
|
170
|
+
coro = self._send_raw(event, data)
|
|
171
|
+
if self._loop and self._loop.is_running():
|
|
172
|
+
try:
|
|
173
|
+
asyncio.ensure_future(coro, loop=self._loop)
|
|
174
|
+
except RuntimeError:
|
|
175
|
+
asyncio.run_coroutine_threadsafe(coro, self._loop)
|
|
176
|
+
elif self._loop:
|
|
177
|
+
asyncio.run_coroutine_threadsafe(coro, self._loop)
|
|
178
|
+
|
|
179
|
+
async def _send_raw(self, event: str, data: Any = None) -> None:
|
|
180
|
+
if self._ws and self._connected:
|
|
181
|
+
try:
|
|
182
|
+
await self._ws.send(json.dumps({"event": event, "data": data}))
|
|
183
|
+
except ConnectionClosed:
|
|
184
|
+
self._connected = False
|
|
185
|
+
except Exception as e:
|
|
186
|
+
logger.error(f"Send error: {e}")
|
|
187
|
+
await self._fire_error(e)
|
|
188
|
+
|
|
189
|
+
async def _fire_single(self, event: str) -> None:
|
|
190
|
+
for handler in self._handlers.get(event, []):
|
|
191
|
+
try:
|
|
192
|
+
result = handler()
|
|
193
|
+
if asyncio.iscoroutine(result):
|
|
194
|
+
await result
|
|
195
|
+
except Exception as e:
|
|
196
|
+
logger.error(f"Error in '{event}' handler: {e}", exc_info=True)
|
|
197
|
+
await self._fire_error(e)
|
|
198
|
+
|
|
199
|
+
async def _fire(self, event: str, data: Any) -> None:
|
|
200
|
+
for handler in self._handlers.get(event, []):
|
|
201
|
+
try:
|
|
202
|
+
result = handler(data)
|
|
203
|
+
if asyncio.iscoroutine(result):
|
|
204
|
+
await result
|
|
205
|
+
except Exception as e:
|
|
206
|
+
logger.error(f"Error in '{event}' handler: {e}", exc_info=True)
|
|
207
|
+
await self._fire_error(e)
|
|
208
|
+
|
|
209
|
+
async def _fire_error(self, error: Exception) -> None:
|
|
210
|
+
if self._error_handlers:
|
|
211
|
+
for handler in self._error_handlers:
|
|
212
|
+
try:
|
|
213
|
+
result = handler(error)
|
|
214
|
+
if asyncio.iscoroutine(result):
|
|
215
|
+
await result
|
|
216
|
+
except Exception as e:
|
|
217
|
+
logger.error(f"Error in error handler: {e}", exc_info=True)
|
|
218
|
+
else:
|
|
219
|
+
logger.error(
|
|
220
|
+
f"Unhandled error (register @client.on_error() to catch): {error}"
|
|
221
|
+
)
|