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.
Files changed (71) hide show
  1. pyphotonrealtime-0.0.1/.gitattributes +2 -0
  2. pyphotonrealtime-0.0.1/.github/workflows/ci.yml +57 -0
  3. pyphotonrealtime-0.0.1/.gitignore +7 -0
  4. pyphotonrealtime-0.0.1/PKG-INFO +93 -0
  5. pyphotonrealtime-0.0.1/README.md +79 -0
  6. pyphotonrealtime-0.0.1/ROADMAP.md +103 -0
  7. pyphotonrealtime-0.0.1/pyproject.toml +31 -0
  8. pyphotonrealtime-0.0.1/ruff.toml +41 -0
  9. pyphotonrealtime-0.0.1/src/pyphotonrealtime/__init__.py +30 -0
  10. pyphotonrealtime-0.0.1/src/pyphotonrealtime/peer.py +192 -0
  11. pyphotonrealtime-0.0.1/src/pyphotonrealtime/protocol/__init__.py +1 -0
  12. pyphotonrealtime-0.0.1/src/pyphotonrealtime/protocol/command_code.py +32 -0
  13. pyphotonrealtime-0.0.1/src/pyphotonrealtime/protocol/consts.py +12 -0
  14. pyphotonrealtime-0.0.1/src/pyphotonrealtime/protocol/deserializer.py +60 -0
  15. pyphotonrealtime-0.0.1/src/pyphotonrealtime/protocol/enum_lookups.py +67 -0
  16. pyphotonrealtime-0.0.1/src/pyphotonrealtime/protocol/event_code.py +61 -0
  17. pyphotonrealtime-0.0.1/src/pyphotonrealtime/protocol/operation_code.py +35 -0
  18. pyphotonrealtime-0.0.1/src/pyphotonrealtime/protocol/packet/__init__.py +4 -0
  19. pyphotonrealtime-0.0.1/src/pyphotonrealtime/protocol/packet/base.py +70 -0
  20. pyphotonrealtime-0.0.1/src/pyphotonrealtime/protocol/packet/disconnect.py +40 -0
  21. pyphotonrealtime-0.0.1/src/pyphotonrealtime/protocol/packet/event_packet.py +30 -0
  22. pyphotonrealtime-0.0.1/src/pyphotonrealtime/protocol/packet/factory.py +99 -0
  23. pyphotonrealtime-0.0.1/src/pyphotonrealtime/protocol/packet/format.py +6 -0
  24. pyphotonrealtime-0.0.1/src/pyphotonrealtime/protocol/packet/header.py +106 -0
  25. pyphotonrealtime-0.0.1/src/pyphotonrealtime/protocol/packet/init.py +133 -0
  26. pyphotonrealtime-0.0.1/src/pyphotonrealtime/protocol/packet/keep_alive.py +54 -0
  27. pyphotonrealtime-0.0.1/src/pyphotonrealtime/protocol/packet/key_exchange.py +125 -0
  28. pyphotonrealtime-0.0.1/src/pyphotonrealtime/protocol/packet/operation_packet.py +110 -0
  29. pyphotonrealtime-0.0.1/src/pyphotonrealtime/protocol/packet/operation_payload.py +127 -0
  30. pyphotonrealtime-0.0.1/src/pyphotonrealtime/protocol/packet/packet_stream.py +166 -0
  31. pyphotonrealtime-0.0.1/src/pyphotonrealtime/protocol/param/__init__.py +0 -0
  32. pyphotonrealtime-0.0.1/src/pyphotonrealtime/protocol/param/base.py +76 -0
  33. pyphotonrealtime-0.0.1/src/pyphotonrealtime/protocol/param/bool_param.py +19 -0
  34. pyphotonrealtime-0.0.1/src/pyphotonrealtime/protocol/param/custom_param.py +29 -0
  35. pyphotonrealtime-0.0.1/src/pyphotonrealtime/protocol/param/dict_param.py +55 -0
  36. pyphotonrealtime-0.0.1/src/pyphotonrealtime/protocol/param/dictionary_param_base.py +65 -0
  37. pyphotonrealtime-0.0.1/src/pyphotonrealtime/protocol/param/float32_param.py +18 -0
  38. pyphotonrealtime-0.0.1/src/pyphotonrealtime/protocol/param/float64_param.py +18 -0
  39. pyphotonrealtime-0.0.1/src/pyphotonrealtime/protocol/param/hashtable_param.py +56 -0
  40. pyphotonrealtime-0.0.1/src/pyphotonrealtime/protocol/param/int32_param.py +18 -0
  41. pyphotonrealtime-0.0.1/src/pyphotonrealtime/protocol/param/int64_param.py +18 -0
  42. pyphotonrealtime-0.0.1/src/pyphotonrealtime/protocol/param/int8_param.py +62 -0
  43. pyphotonrealtime-0.0.1/src/pyphotonrealtime/protocol/param/int8_slice_param.py +21 -0
  44. pyphotonrealtime-0.0.1/src/pyphotonrealtime/protocol/param/nil_param.py +20 -0
  45. pyphotonrealtime-0.0.1/src/pyphotonrealtime/protocol/param/object_slice_param.py +30 -0
  46. pyphotonrealtime-0.0.1/src/pyphotonrealtime/protocol/param/parameter_key.py +62 -0
  47. pyphotonrealtime-0.0.1/src/pyphotonrealtime/protocol/param/parameter_type.py +40 -0
  48. pyphotonrealtime-0.0.1/src/pyphotonrealtime/protocol/param/read_param.py +44 -0
  49. pyphotonrealtime-0.0.1/src/pyphotonrealtime/protocol/param/slice_param.py +57 -0
  50. pyphotonrealtime-0.0.1/src/pyphotonrealtime/protocol/param/string_param.py +28 -0
  51. pyphotonrealtime-0.0.1/src/pyphotonrealtime/protocol/param/type_registry.py +39 -0
  52. pyphotonrealtime-0.0.1/src/pyphotonrealtime/protocol/photon_enc.py +67 -0
  53. pyphotonrealtime-0.0.1/src/pyphotonrealtime/protocol/property_keys.py +35 -0
  54. pyphotonrealtime-0.0.1/src/pyphotonrealtime/protocol/serialization_protocol.py +7 -0
  55. pyphotonrealtime-0.0.1/src/pyphotonrealtime/protocol/serializer.py +48 -0
  56. pyphotonrealtime-0.0.1/src/pyphotonrealtime/py.typed +0 -0
  57. pyphotonrealtime-0.0.1/src/pyphotonrealtime/realtime/__init__.py +46 -0
  58. pyphotonrealtime-0.0.1/src/pyphotonrealtime/realtime/callbacks.py +115 -0
  59. pyphotonrealtime-0.0.1/src/pyphotonrealtime/realtime/client.py +278 -0
  60. pyphotonrealtime-0.0.1/src/pyphotonrealtime/realtime/options.py +70 -0
  61. pyphotonrealtime-0.0.1/src/pyphotonrealtime/realtime/player.py +30 -0
  62. pyphotonrealtime-0.0.1/src/pyphotonrealtime/realtime/room.py +49 -0
  63. pyphotonrealtime-0.0.1/src/pyphotonrealtime/realtime/settings.py +23 -0
  64. pyphotonrealtime-0.0.1/src/pyphotonrealtime/realtime/state.py +54 -0
  65. pyphotonrealtime-0.0.1/src/pyphotonrealtime/transport/__init__.py +6 -0
  66. pyphotonrealtime-0.0.1/src/pyphotonrealtime/transport/base.py +32 -0
  67. pyphotonrealtime-0.0.1/src/pyphotonrealtime/transport/tcp.py +67 -0
  68. pyphotonrealtime-0.0.1/tests/e2e/__init__.py +1 -0
  69. pyphotonrealtime-0.0.1/tests/e2e/test_photon_cloud.py +47 -0
  70. pyphotonrealtime-0.0.1/tests/test_api.py +44 -0
  71. pyphotonrealtime-0.0.1/tests/test_protocol.py +109 -0
@@ -0,0 +1,2 @@
1
+ * text=auto eol=lf
2
+ *.png binary
@@ -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,7 @@
1
+ __pycache__/
2
+ .vscode/PythonImportHelper-v2-Completion.json
3
+ venv/
4
+ venv2/
5
+ .env
6
+ .venv/
7
+ .venv*/
@@ -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,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