pyPhotonRealtime 0.0.1__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.
- pyphotonrealtime-0.0.1/.gitattributes +2 -0
- pyphotonrealtime-0.0.1/.github/workflows/ci.yml +57 -0
- pyphotonrealtime-0.0.1/.gitignore +7 -0
- pyphotonrealtime-0.0.1/PKG-INFO +93 -0
- pyphotonrealtime-0.0.1/README.md +79 -0
- pyphotonrealtime-0.0.1/ROADMAP.md +103 -0
- pyphotonrealtime-0.0.1/pyproject.toml +31 -0
- pyphotonrealtime-0.0.1/ruff.toml +41 -0
- pyphotonrealtime-0.0.1/src/pyphotonrealtime/__init__.py +30 -0
- pyphotonrealtime-0.0.1/src/pyphotonrealtime/peer.py +192 -0
- pyphotonrealtime-0.0.1/src/pyphotonrealtime/protocol/__init__.py +1 -0
- pyphotonrealtime-0.0.1/src/pyphotonrealtime/protocol/command_code.py +32 -0
- pyphotonrealtime-0.0.1/src/pyphotonrealtime/protocol/consts.py +12 -0
- pyphotonrealtime-0.0.1/src/pyphotonrealtime/protocol/deserializer.py +60 -0
- pyphotonrealtime-0.0.1/src/pyphotonrealtime/protocol/enum_lookups.py +67 -0
- pyphotonrealtime-0.0.1/src/pyphotonrealtime/protocol/event_code.py +61 -0
- pyphotonrealtime-0.0.1/src/pyphotonrealtime/protocol/operation_code.py +35 -0
- pyphotonrealtime-0.0.1/src/pyphotonrealtime/protocol/packet/__init__.py +4 -0
- pyphotonrealtime-0.0.1/src/pyphotonrealtime/protocol/packet/base.py +70 -0
- pyphotonrealtime-0.0.1/src/pyphotonrealtime/protocol/packet/disconnect.py +40 -0
- pyphotonrealtime-0.0.1/src/pyphotonrealtime/protocol/packet/event_packet.py +30 -0
- pyphotonrealtime-0.0.1/src/pyphotonrealtime/protocol/packet/factory.py +99 -0
- pyphotonrealtime-0.0.1/src/pyphotonrealtime/protocol/packet/format.py +6 -0
- pyphotonrealtime-0.0.1/src/pyphotonrealtime/protocol/packet/header.py +106 -0
- pyphotonrealtime-0.0.1/src/pyphotonrealtime/protocol/packet/init.py +133 -0
- pyphotonrealtime-0.0.1/src/pyphotonrealtime/protocol/packet/keep_alive.py +54 -0
- pyphotonrealtime-0.0.1/src/pyphotonrealtime/protocol/packet/key_exchange.py +125 -0
- pyphotonrealtime-0.0.1/src/pyphotonrealtime/protocol/packet/operation_packet.py +110 -0
- pyphotonrealtime-0.0.1/src/pyphotonrealtime/protocol/packet/operation_payload.py +127 -0
- pyphotonrealtime-0.0.1/src/pyphotonrealtime/protocol/packet/packet_stream.py +166 -0
- pyphotonrealtime-0.0.1/src/pyphotonrealtime/protocol/param/__init__.py +0 -0
- pyphotonrealtime-0.0.1/src/pyphotonrealtime/protocol/param/base.py +76 -0
- pyphotonrealtime-0.0.1/src/pyphotonrealtime/protocol/param/bool_param.py +19 -0
- pyphotonrealtime-0.0.1/src/pyphotonrealtime/protocol/param/custom_param.py +29 -0
- pyphotonrealtime-0.0.1/src/pyphotonrealtime/protocol/param/dict_param.py +55 -0
- pyphotonrealtime-0.0.1/src/pyphotonrealtime/protocol/param/dictionary_param_base.py +65 -0
- pyphotonrealtime-0.0.1/src/pyphotonrealtime/protocol/param/float32_param.py +18 -0
- pyphotonrealtime-0.0.1/src/pyphotonrealtime/protocol/param/float64_param.py +18 -0
- pyphotonrealtime-0.0.1/src/pyphotonrealtime/protocol/param/hashtable_param.py +56 -0
- pyphotonrealtime-0.0.1/src/pyphotonrealtime/protocol/param/int32_param.py +18 -0
- pyphotonrealtime-0.0.1/src/pyphotonrealtime/protocol/param/int64_param.py +18 -0
- pyphotonrealtime-0.0.1/src/pyphotonrealtime/protocol/param/int8_param.py +62 -0
- pyphotonrealtime-0.0.1/src/pyphotonrealtime/protocol/param/int8_slice_param.py +21 -0
- pyphotonrealtime-0.0.1/src/pyphotonrealtime/protocol/param/nil_param.py +20 -0
- pyphotonrealtime-0.0.1/src/pyphotonrealtime/protocol/param/object_slice_param.py +30 -0
- pyphotonrealtime-0.0.1/src/pyphotonrealtime/protocol/param/parameter_key.py +62 -0
- pyphotonrealtime-0.0.1/src/pyphotonrealtime/protocol/param/parameter_type.py +40 -0
- pyphotonrealtime-0.0.1/src/pyphotonrealtime/protocol/param/read_param.py +44 -0
- pyphotonrealtime-0.0.1/src/pyphotonrealtime/protocol/param/slice_param.py +57 -0
- pyphotonrealtime-0.0.1/src/pyphotonrealtime/protocol/param/string_param.py +28 -0
- pyphotonrealtime-0.0.1/src/pyphotonrealtime/protocol/param/type_registry.py +39 -0
- pyphotonrealtime-0.0.1/src/pyphotonrealtime/protocol/photon_enc.py +67 -0
- pyphotonrealtime-0.0.1/src/pyphotonrealtime/protocol/property_keys.py +35 -0
- pyphotonrealtime-0.0.1/src/pyphotonrealtime/protocol/serialization_protocol.py +7 -0
- pyphotonrealtime-0.0.1/src/pyphotonrealtime/protocol/serializer.py +48 -0
- pyphotonrealtime-0.0.1/src/pyphotonrealtime/py.typed +0 -0
- pyphotonrealtime-0.0.1/src/pyphotonrealtime/realtime/__init__.py +46 -0
- pyphotonrealtime-0.0.1/src/pyphotonrealtime/realtime/callbacks.py +115 -0
- pyphotonrealtime-0.0.1/src/pyphotonrealtime/realtime/client.py +278 -0
- pyphotonrealtime-0.0.1/src/pyphotonrealtime/realtime/options.py +70 -0
- pyphotonrealtime-0.0.1/src/pyphotonrealtime/realtime/player.py +30 -0
- pyphotonrealtime-0.0.1/src/pyphotonrealtime/realtime/room.py +49 -0
- pyphotonrealtime-0.0.1/src/pyphotonrealtime/realtime/settings.py +23 -0
- pyphotonrealtime-0.0.1/src/pyphotonrealtime/realtime/state.py +54 -0
- pyphotonrealtime-0.0.1/src/pyphotonrealtime/transport/__init__.py +6 -0
- pyphotonrealtime-0.0.1/src/pyphotonrealtime/transport/base.py +32 -0
- pyphotonrealtime-0.0.1/src/pyphotonrealtime/transport/tcp.py +67 -0
- pyphotonrealtime-0.0.1/tests/e2e/__init__.py +1 -0
- pyphotonrealtime-0.0.1/tests/e2e/test_photon_cloud.py +47 -0
- pyphotonrealtime-0.0.1/tests/test_api.py +44 -0
- pyphotonrealtime-0.0.1/tests/test_protocol.py +109 -0
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: ["master"]
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: ["master"]
|
|
8
|
+
workflow_dispatch:
|
|
9
|
+
schedule:
|
|
10
|
+
- cron: "17 4 * * *" # nightly e2e against Photon Cloud
|
|
11
|
+
|
|
12
|
+
jobs:
|
|
13
|
+
lint:
|
|
14
|
+
runs-on: ubuntu-latest
|
|
15
|
+
steps:
|
|
16
|
+
- uses: actions/checkout@v7
|
|
17
|
+
- uses: actions/setup-python@v7
|
|
18
|
+
with:
|
|
19
|
+
python-version: "3.12"
|
|
20
|
+
- name: Install pinned ruff, mypy and test deps
|
|
21
|
+
run: python -m pip install -e ".[lint,test]"
|
|
22
|
+
- name: Ruff check
|
|
23
|
+
run: python -m ruff check .
|
|
24
|
+
- name: Ruff format check
|
|
25
|
+
run: python -m ruff format --check .
|
|
26
|
+
- name: Mypy (strict)
|
|
27
|
+
run: python -m mypy
|
|
28
|
+
|
|
29
|
+
test:
|
|
30
|
+
needs: lint
|
|
31
|
+
runs-on: ubuntu-latest
|
|
32
|
+
strategy:
|
|
33
|
+
matrix:
|
|
34
|
+
python-version: ["3.12", "3.13", "3.14"]
|
|
35
|
+
steps:
|
|
36
|
+
- uses: actions/checkout@v7
|
|
37
|
+
- uses: actions/setup-python@v7
|
|
38
|
+
with:
|
|
39
|
+
python-version: ${{ matrix.python-version }}
|
|
40
|
+
- run: python -m pip install -e ".[test]"
|
|
41
|
+
- run: python -m pytest -q
|
|
42
|
+
|
|
43
|
+
e2e:
|
|
44
|
+
# Live servers: nightly and on demand only, never on PRs (forks have no secret).
|
|
45
|
+
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
|
|
46
|
+
needs: test
|
|
47
|
+
runs-on: ubuntu-latest
|
|
48
|
+
steps:
|
|
49
|
+
- uses: actions/checkout@v7
|
|
50
|
+
- uses: actions/setup-python@v7
|
|
51
|
+
with:
|
|
52
|
+
python-version: "3.12"
|
|
53
|
+
- run: python -m pip install -e ".[test]"
|
|
54
|
+
- name: Run e2e tests
|
|
55
|
+
env:
|
|
56
|
+
PHOTON_APP_ID: ${{ secrets.PHOTON_APP_ID }}
|
|
57
|
+
run: python -m pytest -m e2e -rxs
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: pyPhotonRealtime
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Pure-Python client for games running on Photon Realtime
|
|
5
|
+
Author: Michael K. Steinberg
|
|
6
|
+
Requires-Python: >=3.12
|
|
7
|
+
Requires-Dist: pycryptodome>=3.20
|
|
8
|
+
Provides-Extra: lint
|
|
9
|
+
Requires-Dist: mypy==2.4.0; extra == 'lint'
|
|
10
|
+
Requires-Dist: ruff==0.16.10; extra == 'lint'
|
|
11
|
+
Provides-Extra: test
|
|
12
|
+
Requires-Dist: pytest>=8; extra == 'test'
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# pyPhotonRealtime
|
|
16
|
+
|
|
17
|
+
A pure-Python client for games running on [Photon Realtime](https://doc.photonengine.com/realtime-core/v6/getting-started/realtime-intro).
|
|
18
|
+
Connect, matchmake, join rooms, raise events and track properties and players,
|
|
19
|
+
all from Python.
|
|
20
|
+
|
|
21
|
+
> **Status: pre-alpha.** The wire layer works (it came from
|
|
22
|
+
> [prison-architect-server](https://github.com/Michael-K-Stein/prison-architect-server)).
|
|
23
|
+
> The client workflow is still scaffolding. See [ROADMAP.md](ROADMAP.md).
|
|
24
|
+
|
|
25
|
+
## Install
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pip install -e ".[test,lint]" # from a checkout; not on PyPI yet
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Requires Python 3.12+. Fully type-annotated (`py.typed`), checked with `mypy --strict`.
|
|
32
|
+
|
|
33
|
+
## Intended usage
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
import time
|
|
37
|
+
from pyphotonrealtime import AppSettings, RealtimeClient
|
|
38
|
+
from pyphotonrealtime.realtime import (
|
|
39
|
+
ConnectionCallbacks,
|
|
40
|
+
MatchmakingCallbacks,
|
|
41
|
+
EnterRoomParams,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Bot(ConnectionCallbacks, MatchmakingCallbacks):
|
|
46
|
+
def __init__(self, client):
|
|
47
|
+
self.client = client
|
|
48
|
+
|
|
49
|
+
def on_connected_to_master(self):
|
|
50
|
+
self.client.op_join_or_create_room(EnterRoomParams(room_name="lobby-1"))
|
|
51
|
+
|
|
52
|
+
def on_joined_room(self):
|
|
53
|
+
self.client.op_raise_event(1, {"hello": "world"})
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
client = RealtimeClient()
|
|
57
|
+
client.add_callback_target(Bot(client))
|
|
58
|
+
client.connect_using_settings(
|
|
59
|
+
AppSettings(app_id_realtime="<your app id>", fixed_region="eu")
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
while True:
|
|
63
|
+
client.service() # nothing happens on the wire without this
|
|
64
|
+
time.sleep(1 / 30)
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Layout
|
|
68
|
+
|
|
69
|
+
| Package | Role |
|
|
70
|
+
|---|---|
|
|
71
|
+
| `pyphotonrealtime.protocol` | Wire format: typed parameters, packet framing, DH/AES |
|
|
72
|
+
| `pyphotonrealtime.transport` | Byte transports (TCP; UDP/WebSocket planned) |
|
|
73
|
+
| `pyphotonrealtime.peer` | `PhotonPeer`: one connection, the service loop, keep-alive, encryption |
|
|
74
|
+
| `pyphotonrealtime.realtime` | `RealtimeClient`: Name/Master/Game server workflow, rooms, players, callbacks |
|
|
75
|
+
|
|
76
|
+
## Development
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
python -m ruff --version # must match the pin in pyproject.toml
|
|
80
|
+
python -m ruff check . # select = ["ALL"], see ruff.toml
|
|
81
|
+
python -m ruff format --check .
|
|
82
|
+
python -m mypy # strict
|
|
83
|
+
python -m pytest -q # unit tests (e2e excluded)
|
|
84
|
+
PHOTON_APP_ID=... python -m pytest -m e2e # live Photon Cloud, opt-in
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## Testing
|
|
88
|
+
|
|
89
|
+
Unit tests check that the protocol layer agrees with itself (round trips, framing).
|
|
90
|
+
That doesn't prove it agrees with Photon. The `e2e` tests in `tests/e2e/` do that
|
|
91
|
+
against the real Photon Cloud. They need a Realtime app id in `PHOTON_APP_ID`, and
|
|
92
|
+
CI runs them nightly and on manual dispatch, never on PRs.
|
|
93
|
+
```
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# pyPhotonRealtime
|
|
2
|
+
|
|
3
|
+
A pure-Python client for games running on [Photon Realtime](https://doc.photonengine.com/realtime-core/v6/getting-started/realtime-intro).
|
|
4
|
+
Connect, matchmake, join rooms, raise events and track properties and players,
|
|
5
|
+
all from Python.
|
|
6
|
+
|
|
7
|
+
> **Status: pre-alpha.** The wire layer works (it came from
|
|
8
|
+
> [prison-architect-server](https://github.com/Michael-K-Stein/prison-architect-server)).
|
|
9
|
+
> The client workflow is still scaffolding. See [ROADMAP.md](ROADMAP.md).
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pip install -e ".[test,lint]" # from a checkout; not on PyPI yet
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Requires Python 3.12+. Fully type-annotated (`py.typed`), checked with `mypy --strict`.
|
|
18
|
+
|
|
19
|
+
## Intended usage
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
import time
|
|
23
|
+
from pyphotonrealtime import AppSettings, RealtimeClient
|
|
24
|
+
from pyphotonrealtime.realtime import (
|
|
25
|
+
ConnectionCallbacks,
|
|
26
|
+
MatchmakingCallbacks,
|
|
27
|
+
EnterRoomParams,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Bot(ConnectionCallbacks, MatchmakingCallbacks):
|
|
32
|
+
def __init__(self, client):
|
|
33
|
+
self.client = client
|
|
34
|
+
|
|
35
|
+
def on_connected_to_master(self):
|
|
36
|
+
self.client.op_join_or_create_room(EnterRoomParams(room_name="lobby-1"))
|
|
37
|
+
|
|
38
|
+
def on_joined_room(self):
|
|
39
|
+
self.client.op_raise_event(1, {"hello": "world"})
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
client = RealtimeClient()
|
|
43
|
+
client.add_callback_target(Bot(client))
|
|
44
|
+
client.connect_using_settings(
|
|
45
|
+
AppSettings(app_id_realtime="<your app id>", fixed_region="eu")
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
while True:
|
|
49
|
+
client.service() # nothing happens on the wire without this
|
|
50
|
+
time.sleep(1 / 30)
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Layout
|
|
54
|
+
|
|
55
|
+
| Package | Role |
|
|
56
|
+
|---|---|
|
|
57
|
+
| `pyphotonrealtime.protocol` | Wire format: typed parameters, packet framing, DH/AES |
|
|
58
|
+
| `pyphotonrealtime.transport` | Byte transports (TCP; UDP/WebSocket planned) |
|
|
59
|
+
| `pyphotonrealtime.peer` | `PhotonPeer`: one connection, the service loop, keep-alive, encryption |
|
|
60
|
+
| `pyphotonrealtime.realtime` | `RealtimeClient`: Name/Master/Game server workflow, rooms, players, callbacks |
|
|
61
|
+
|
|
62
|
+
## Development
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
python -m ruff --version # must match the pin in pyproject.toml
|
|
66
|
+
python -m ruff check . # select = ["ALL"], see ruff.toml
|
|
67
|
+
python -m ruff format --check .
|
|
68
|
+
python -m mypy # strict
|
|
69
|
+
python -m pytest -q # unit tests (e2e excluded)
|
|
70
|
+
PHOTON_APP_ID=... python -m pytest -m e2e # live Photon Cloud, opt-in
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Testing
|
|
74
|
+
|
|
75
|
+
Unit tests check that the protocol layer agrees with itself (round trips, framing).
|
|
76
|
+
That doesn't prove it agrees with Photon. The `e2e` tests in `tests/e2e/` do that
|
|
77
|
+
against the real Photon Cloud. They need a Realtime app id in `PHOTON_APP_ID`, and
|
|
78
|
+
CI runs them nightly and on manual dispatch, never on PRs.
|
|
79
|
+
```
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# Roadmap
|
|
2
|
+
|
|
3
|
+
Goal: be able to do from Python everything a Photon Realtime game does, so a
|
|
4
|
+
game client (or a bot, test harness or proxy) can be written against it. Once it's stable,
|
|
5
|
+
`prison-architect-server` replaces its inline `server/photon` with this package.
|
|
6
|
+
|
|
7
|
+
## Architecture
|
|
8
|
+
|
|
9
|
+
The layers follow the official SDKs, so their docs stay usable:
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
RealtimeClient (realtime/) state machine, NS -> MS -> GS hops, rooms, callbacks
|
|
13
|
+
|
|
|
14
|
+
PhotonPeer (peer.py) service loop, op/event queues, keep-alive, timeouts, crypto
|
|
15
|
+
|
|
|
16
|
+
Transport (transport/) TCP | UDP (ENet-style reliability) | WebSocket(s)
|
|
17
|
+
|
|
|
18
|
+
protocol/ Protocol16/18 types, framing, DH key exchange, AES
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Threading model: like the SDKs, the client is single-threaded and driven by `service()`.
|
|
22
|
+
Every callback runs inside `service()`. An `asyncio` wrapper comes later as a
|
|
23
|
+
separate layer and doesn't change the core.
|
|
24
|
+
|
|
25
|
+
## Milestones
|
|
26
|
+
|
|
27
|
+
### M0: Fork and scaffold (done)
|
|
28
|
+
- [x] Extract `server/photon` (history preserved) into `pyphotonrealtime.protocol`
|
|
29
|
+
- [x] Decouple from server-only code (`GamesManager`, logging, settings, queue)
|
|
30
|
+
- [x] Skeletons: `Transport`, `PhotonPeer`, `RealtimeClient`, callbacks, options, Room/Player
|
|
31
|
+
- [x] Packaging (`pyproject.toml`, src layout, `py.typed`), Python 3.12+
|
|
32
|
+
- [x] CI: ruff `ALL` (pinned), `mypy --strict`, pytest on 3.12-3.14, nightly e2e job
|
|
33
|
+
|
|
34
|
+
### M1: PhotonPeer over TCP
|
|
35
|
+
- [ ] `connect`: send `InitRequest`, handle `InitResponse`, status callbacks
|
|
36
|
+
- [ ] `send_operation` / `dispatch_incoming_commands` into `OperationResponse` / `EventData`
|
|
37
|
+
- [ ] Keep-alive (client-initiated pings) and disconnect timeout
|
|
38
|
+
- [ ] `establish_encryption` (DH via `photon_enc`), encrypted ops (AES-CBC)
|
|
39
|
+
- [ ] Traffic stats and round-trip time
|
|
40
|
+
- [ ] Tests against a fake in-process server (reuse PA server's server-side code as a fixture)
|
|
41
|
+
- [ ] First e2e test passing: TCP connect + Init to `ns.photonengine.io`
|
|
42
|
+
- [ ] Buffer partial TCP writes (`TcpTransport.send` currently blocks)
|
|
43
|
+
|
|
44
|
+
### M2: RealtimeClient connection workflow
|
|
45
|
+
- [ ] Name Server: `OpGetRegions`, `OpAuthenticate` / `AuthOnce`, region pinging
|
|
46
|
+
- [ ] Master Server hop with token; direct-master mode (`use_name_server=False`)
|
|
47
|
+
- [ ] Disconnect causes, `reconnect_and_rejoin`
|
|
48
|
+
- [ ] Custom authentication (`AuthenticationValues`)
|
|
49
|
+
- [ ] e2e: Name Server -> Master Server against Photon Cloud (`tests/e2e`, currently xfail)
|
|
50
|
+
|
|
51
|
+
### M3: Matchmaking and lobbies
|
|
52
|
+
- [ ] Join/leave lobby, room list and `GameListUpdate` events, lobby stats
|
|
53
|
+
- [ ] Create / Join / JoinOrCreate / JoinRandom / Rejoin, then the Game Server hop
|
|
54
|
+
- [ ] `OpFindFriends`, expected users, SQL lobby filters
|
|
55
|
+
|
|
56
|
+
### M4: In-room
|
|
57
|
+
- [ ] Join/Leave/PropertiesChanged events, then update `Room`/`Player` and fire callbacks
|
|
58
|
+
- [ ] `op_raise_event` (receivers, target actors, caching, interest groups)
|
|
59
|
+
- [ ] Set room/actor properties, with expected-properties CAS
|
|
60
|
+
- [ ] Master client switching; `op_custom` escape hatch
|
|
61
|
+
- [ ] e2e: two clients in one room exchange events and properties
|
|
62
|
+
|
|
63
|
+
### M5: Protocol completeness
|
|
64
|
+
- [ ] Protocol 1.8 (`SerializationProtocol.V18`), the default in v5+ SDKs; audit what's implemented today
|
|
65
|
+
- [ ] Custom types registry (`PhotonPeer.RegisterType` equivalent) as a public API
|
|
66
|
+
- [ ] UDP transport with reliable/unreliable channels and fragmentation
|
|
67
|
+
- [ ] WebSocket(s) transport
|
|
68
|
+
|
|
69
|
+
### M6: Ergonomics and release
|
|
70
|
+
- [ ] `asyncio` facade (`AsyncRealtimeClient`: awaitable connect/join, async event iterator)
|
|
71
|
+
- [ ] Plain Python values in the public API (`dict`/`int`/`str`) instead of `*Parameter` wrappers;
|
|
72
|
+
automatic conversion at the peer boundary
|
|
73
|
+
- [ ] Docs site, examples (bot, room browser, packet sniffer)
|
|
74
|
+
- [ ] Publish `pyPhotonRealtime` to PyPI (trusted publishing on tag)
|
|
75
|
+
- [ ] Migrate prison-architect-server onto it
|
|
76
|
+
|
|
77
|
+
## Clean-up inherited from the fork
|
|
78
|
+
- `protocol/event_code.py` has `PrisonArchitectEventCode`. It's game-specific and should move back
|
|
79
|
+
to the PA repo before 1.0.
|
|
80
|
+
- `OperationCode` mixes client ops with internal aliases (`Authenticate`/`GameList` share 230);
|
|
81
|
+
split into `OperationCode` / `EventCode` / `ParameterCode` the way the SDK does.
|
|
82
|
+
- `protocol/` is exempt from the missing-docstring rules (D100-D107) in `ruff.toml`. Document it
|
|
83
|
+
and drop the exemption. Every other rule, and `mypy --strict`, already applies to it.
|
|
84
|
+
- `PacketFactory.event` casts a `PhotonOperationPacket` to `PhotonEventPacket`. Construct the real type.
|
|
85
|
+
|
|
86
|
+
## Decision: dataclasses vs. pydantic v2
|
|
87
|
+
|
|
88
|
+
User-facing config (`AppSettings`, `RoomOptions`, `EnterRoomParams`, ...) uses stdlib
|
|
89
|
+
`dataclasses` for now.
|
|
90
|
+
|
|
91
|
+
| | pydantic v2 | dataclasses |
|
|
92
|
+
|---|---|---|
|
|
93
|
+
| Validation (types, ranges such as `max_players` 0-255) | built in | manual `__post_init__` |
|
|
94
|
+
| Load settings from env / JSON / `.env` (`pydantic-settings`) | yes | manual |
|
|
95
|
+
| JSON schema / serialization of config | yes | manual |
|
|
96
|
+
| Dependency weight | `pydantic-core` Rust wheel (~2 MB); no wheels on some niche platforms/PyPy versions | none |
|
|
97
|
+
| Hot-path cost (per event/packet) | model construction is slower | negligible |
|
|
98
|
+
| Version conflicts for users embedding the lib | possible (v1/v2 pin clashes) | none |
|
|
99
|
+
|
|
100
|
+
Conclusion: the wire and peer layers (packets, `EventData`, `OperationResponse`) never use pydantic,
|
|
101
|
+
because they're per-packet hot paths. For config, the validation benefit is real but small,
|
|
102
|
+
and a hard dependency costs every user. If validation gets painful, add an optional
|
|
103
|
+
`pyPhotonRealtime[pydantic]` extra with validated config models, or switch only `AppSettings`.
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.25"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "pyPhotonRealtime"
|
|
7
|
+
version = "0.0.1"
|
|
8
|
+
description = "Pure-Python client for games running on Photon Realtime"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.12"
|
|
11
|
+
authors = [{ name = "Michael K. Steinberg" }]
|
|
12
|
+
dependencies = [
|
|
13
|
+
"pycryptodome>=3.20", # AES for encrypted operations / DH key exchange
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
[project.optional-dependencies]
|
|
17
|
+
lint = ["ruff==0.16.10", "mypy==2.4.0"]
|
|
18
|
+
test = ["pytest>=8"]
|
|
19
|
+
|
|
20
|
+
[tool.hatch.build.targets.wheel]
|
|
21
|
+
packages = ["src/pyphotonrealtime"]
|
|
22
|
+
|
|
23
|
+
[tool.pytest.ini_options]
|
|
24
|
+
testpaths = ["tests"]
|
|
25
|
+
addopts = ["-m", "not e2e", "--strict-markers"]
|
|
26
|
+
markers = ["e2e: talks to the real Photon Cloud (needs PHOTON_APP_ID)"]
|
|
27
|
+
|
|
28
|
+
[tool.mypy]
|
|
29
|
+
python_version = "3.12"
|
|
30
|
+
strict = true
|
|
31
|
+
files = ["src", "tests"]
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# Strict: every rule is on. Keep the ruff pin in pyproject.toml (`lint` extra)
|
|
2
|
+
# in sync with CI. Opt out per line with `# noqa: XXX` plus a reason, not here.
|
|
3
|
+
target-version = "py312"
|
|
4
|
+
line-length = 88
|
|
5
|
+
src = ["src", "tests"]
|
|
6
|
+
|
|
7
|
+
[lint]
|
|
8
|
+
select = ["ALL"]
|
|
9
|
+
ignore = [
|
|
10
|
+
# Conflict with the formatter (https://docs.astral.sh/ruff/formatter/#conflicting-lint-rules).
|
|
11
|
+
"COM812",
|
|
12
|
+
"ISC001",
|
|
13
|
+
# Mutually exclusive docstring styles; we use D211 + D212.
|
|
14
|
+
"D203",
|
|
15
|
+
"D213",
|
|
16
|
+
# No per-file copyright headers.
|
|
17
|
+
"CPY001",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
# Lets TC00x move typing-only imports under TYPE_CHECKING by adding
|
|
21
|
+
# `from __future__ import annotations` (3.12/3.13 evaluate annotations eagerly).
|
|
22
|
+
future-annotations = true
|
|
23
|
+
|
|
24
|
+
[lint.per-file-ignores]
|
|
25
|
+
# Inherited from prison-architect-server and not yet documented; see ROADMAP.md.
|
|
26
|
+
"src/pyphotonrealtime/protocol/**" = ["D100", "D101", "D102", "D103", "D104", "D105", "D107"]
|
|
27
|
+
"tests/**" = [
|
|
28
|
+
"S101", # assert is how pytest works
|
|
29
|
+
"D", # test names are the documentation
|
|
30
|
+
"PLR2004", # magic values are fine in assertions
|
|
31
|
+
"SLF001", # tests may poke private members
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
[lint.pydocstyle]
|
|
35
|
+
convention = "google"
|
|
36
|
+
|
|
37
|
+
[lint.flake8-type-checking]
|
|
38
|
+
strict = true
|
|
39
|
+
|
|
40
|
+
[format]
|
|
41
|
+
docstring-code-format = true
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Pure-Python client for games running on Photon Realtime.
|
|
2
|
+
|
|
3
|
+
Layers, bottom to top:
|
|
4
|
+
|
|
5
|
+
* :mod:`pyphotonrealtime.protocol` -- wire format: parameter (de)serialization,
|
|
6
|
+
packet framing, encryption primitives.
|
|
7
|
+
* :mod:`pyphotonrealtime.transport` -- byte transports (TCP now; UDP/WebSocket later).
|
|
8
|
+
* :mod:`pyphotonrealtime.peer` -- ``PhotonPeer``: one connection to one server,
|
|
9
|
+
operation/event/response queues, keep-alive, timeouts, encryption.
|
|
10
|
+
* :mod:`pyphotonrealtime.realtime` -- ``RealtimeClient``: Name Server -> Master
|
|
11
|
+
Server -> Game Server workflow, matchmaking, rooms, players, callbacks.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from pyphotonrealtime.peer import PeerState, PhotonPeer
|
|
15
|
+
from pyphotonrealtime.realtime import (
|
|
16
|
+
AppSettings,
|
|
17
|
+
ClientState,
|
|
18
|
+
RealtimeClient,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
__version__ = "0.0.1"
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"AppSettings",
|
|
25
|
+
"ClientState",
|
|
26
|
+
"PeerState",
|
|
27
|
+
"PhotonPeer",
|
|
28
|
+
"RealtimeClient",
|
|
29
|
+
"__version__",
|
|
30
|
+
]
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""``PhotonPeer``: a single connection to a single Photon server.
|
|
2
|
+
|
|
3
|
+
Mirrors the official SDKs' service loop: nothing happens on the network unless
|
|
4
|
+
the application calls :meth:`PhotonPeer.service` (or the two halves,
|
|
5
|
+
:meth:`PhotonPeer.dispatch_incoming_commands` and
|
|
6
|
+
:meth:`PhotonPeer.send_outgoing_commands`) regularly, typically 10-50 times per
|
|
7
|
+
second, from a single thread.
|
|
8
|
+
|
|
9
|
+
Callbacks to the :class:`PeerListener` are only ever invoked from inside
|
|
10
|
+
``dispatch_incoming_commands``, i.e. on the thread that calls ``service``.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import time
|
|
16
|
+
from collections import deque
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from enum import Enum, auto
|
|
19
|
+
from typing import TYPE_CHECKING, Any, Protocol
|
|
20
|
+
|
|
21
|
+
from pyphotonrealtime.protocol.packet.packet_stream import PhotonStreamParser
|
|
22
|
+
from pyphotonrealtime.transport import TcpTransport
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from pyphotonrealtime.protocol.packet.base import PhotonPacket
|
|
26
|
+
from pyphotonrealtime.protocol.param.base import ParameterBase
|
|
27
|
+
from pyphotonrealtime.transport import Transport
|
|
28
|
+
|
|
29
|
+
type Parameters = dict[int, ParameterBase[Any]]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class PeerState(Enum):
|
|
33
|
+
"""Connection state of a :class:`PhotonPeer`."""
|
|
34
|
+
|
|
35
|
+
Disconnected = auto()
|
|
36
|
+
Connecting = auto()
|
|
37
|
+
Connected = auto()
|
|
38
|
+
Disconnecting = auto()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class StatusCode(Enum):
|
|
42
|
+
"""Peer status changes reported to :meth:`PeerListener.on_status_changed`."""
|
|
43
|
+
|
|
44
|
+
Connect = auto()
|
|
45
|
+
Disconnect = auto()
|
|
46
|
+
ExceptionOnConnect = auto()
|
|
47
|
+
Exception = auto()
|
|
48
|
+
TimeoutDisconnect = auto()
|
|
49
|
+
DisconnectByServer = auto()
|
|
50
|
+
EncryptionEstablished = auto()
|
|
51
|
+
EncryptionFailedToEstablish = auto()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True, slots=True)
|
|
55
|
+
class OperationResponse:
|
|
56
|
+
"""The server's answer to an operation sent with ``send_operation``."""
|
|
57
|
+
|
|
58
|
+
operation_code: int
|
|
59
|
+
return_code: int
|
|
60
|
+
debug_message: str | None
|
|
61
|
+
parameters: Parameters = field(default_factory=dict)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True, slots=True)
|
|
65
|
+
class EventData:
|
|
66
|
+
"""A server- or player-originated event."""
|
|
67
|
+
|
|
68
|
+
code: int
|
|
69
|
+
parameters: Parameters = field(default_factory=dict)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class PeerListener(Protocol):
|
|
73
|
+
"""Receives everything a peer dispatches (``IPhotonPeerListener``)."""
|
|
74
|
+
|
|
75
|
+
def on_status_changed(self, status: StatusCode) -> None:
|
|
76
|
+
"""Handle a connection status change."""
|
|
77
|
+
|
|
78
|
+
def on_operation_response(self, response: OperationResponse) -> None:
|
|
79
|
+
"""Handle the response to an operation."""
|
|
80
|
+
|
|
81
|
+
def on_event(self, event: EventData) -> None:
|
|
82
|
+
"""Handle an incoming event."""
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class PhotonPeer:
|
|
86
|
+
"""One connection to one Photon server, driven by :meth:`service`."""
|
|
87
|
+
|
|
88
|
+
def __init__(
|
|
89
|
+
self,
|
|
90
|
+
listener: PeerListener,
|
|
91
|
+
transport: Transport | None = None,
|
|
92
|
+
*,
|
|
93
|
+
disconnect_timeout: float = 10.0,
|
|
94
|
+
keep_alive_interval: float = 2.0,
|
|
95
|
+
) -> None:
|
|
96
|
+
"""Create a disconnected peer.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
listener: Receives status changes, responses and events.
|
|
100
|
+
transport: Byte transport to use; defaults to TCP.
|
|
101
|
+
disconnect_timeout: Seconds without traffic before giving up.
|
|
102
|
+
keep_alive_interval: Seconds of idle sending before a ping.
|
|
103
|
+
"""
|
|
104
|
+
self.listener = listener
|
|
105
|
+
self.transport = transport if transport is not None else TcpTransport()
|
|
106
|
+
self.disconnect_timeout = disconnect_timeout
|
|
107
|
+
self.keep_alive_interval = keep_alive_interval
|
|
108
|
+
|
|
109
|
+
self.state = PeerState.Disconnected
|
|
110
|
+
self.server_address: str | None = None
|
|
111
|
+
self.app_id: str | None = None
|
|
112
|
+
|
|
113
|
+
self._parser = PhotonStreamParser()
|
|
114
|
+
self._outgoing: deque[PhotonPacket] = deque()
|
|
115
|
+
self._incoming: deque[PhotonPacket] = deque()
|
|
116
|
+
self._aes_key: bytes | None = None
|
|
117
|
+
self._last_receive = 0.0
|
|
118
|
+
self._last_send = 0.0
|
|
119
|
+
|
|
120
|
+
# -- connection -------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
def connect(self, server_address: str, app_id: str) -> bool:
|
|
123
|
+
"""Start connecting to ``host:port``; the result arrives as a status.
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
Whether the connection attempt was started.
|
|
127
|
+
"""
|
|
128
|
+
# M1: send InitRequestPacket(app id, protocol version), move to
|
|
129
|
+
# Connected on InitResponse.
|
|
130
|
+
raise NotImplementedError
|
|
131
|
+
|
|
132
|
+
def disconnect(self) -> None:
|
|
133
|
+
"""Send a disconnect message, then close the transport."""
|
|
134
|
+
raise NotImplementedError
|
|
135
|
+
|
|
136
|
+
def establish_encryption(self) -> bool:
|
|
137
|
+
"""Start a Diffie-Hellman key exchange (``InitEncryptionRequest``).
|
|
138
|
+
|
|
139
|
+
Returns:
|
|
140
|
+
Whether the exchange was started.
|
|
141
|
+
"""
|
|
142
|
+
raise NotImplementedError
|
|
143
|
+
|
|
144
|
+
@property
|
|
145
|
+
def is_encryption_available(self) -> bool:
|
|
146
|
+
"""Whether a shared AES key has been negotiated."""
|
|
147
|
+
return self._aes_key is not None
|
|
148
|
+
|
|
149
|
+
# -- service loop -----------------------------------------------------
|
|
150
|
+
|
|
151
|
+
def service(self) -> None:
|
|
152
|
+
"""Dispatch everything received, then send everything queued."""
|
|
153
|
+
while self.dispatch_incoming_commands():
|
|
154
|
+
pass
|
|
155
|
+
while self.send_outgoing_commands():
|
|
156
|
+
pass
|
|
157
|
+
|
|
158
|
+
def dispatch_incoming_commands(self) -> bool:
|
|
159
|
+
"""Read from the transport and dispatch at most one command.
|
|
160
|
+
|
|
161
|
+
Returns:
|
|
162
|
+
Whether there may be more to dispatch.
|
|
163
|
+
"""
|
|
164
|
+
# M1: map parsed packets to OperationResponse/EventData, answer
|
|
165
|
+
# keep-alives, detect timeouts (disconnect_timeout).
|
|
166
|
+
raise NotImplementedError
|
|
167
|
+
|
|
168
|
+
def send_outgoing_commands(self) -> bool:
|
|
169
|
+
"""Flush queued commands to the transport.
|
|
170
|
+
|
|
171
|
+
Returns:
|
|
172
|
+
Whether more remain to be sent.
|
|
173
|
+
"""
|
|
174
|
+
# M1: also emit a keep-alive when idle for keep_alive_interval.
|
|
175
|
+
raise NotImplementedError
|
|
176
|
+
|
|
177
|
+
# -- operations -------------------------------------------------------
|
|
178
|
+
|
|
179
|
+
def send_operation(
|
|
180
|
+
self, operation_code: int, parameters: Parameters, *, encrypt: bool = False
|
|
181
|
+
) -> bool:
|
|
182
|
+
"""Queue an operation; it goes out on the next ``send_outgoing_commands``.
|
|
183
|
+
|
|
184
|
+
Returns:
|
|
185
|
+
Whether the operation was queued.
|
|
186
|
+
"""
|
|
187
|
+
# M1: build via PacketFactory.operation (encrypted when requested).
|
|
188
|
+
raise NotImplementedError
|
|
189
|
+
|
|
190
|
+
@staticmethod
|
|
191
|
+
def _now() -> float:
|
|
192
|
+
return time.monotonic()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__all__ = []
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from enum import IntEnum
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class CommandCode(IntEnum):
|
|
5
|
+
Init = 0
|
|
6
|
+
InitResponse = 1
|
|
7
|
+
|
|
8
|
+
Operation = 2
|
|
9
|
+
OperationResponse = 3
|
|
10
|
+
|
|
11
|
+
Event = 4
|
|
12
|
+
DisconnectMessage = 5
|
|
13
|
+
|
|
14
|
+
KeyExchangeRequest = 6
|
|
15
|
+
KeyExchangeResponse = 7
|
|
16
|
+
|
|
17
|
+
Message = 8
|
|
18
|
+
RawMessage = 9
|
|
19
|
+
|
|
20
|
+
EncryptedOperation = 0x82
|
|
21
|
+
EncryptedOperationResponse = 0x83
|
|
22
|
+
|
|
23
|
+
EncryptedEvent = 0x84
|
|
24
|
+
|
|
25
|
+
Unknown = 0xFF
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
ENCRYPTED_FLAG = 0x80
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def is_encrypted(command_code: CommandCode) -> bool:
|
|
32
|
+
return command_code.value > ENCRYPTED_FLAG
|