From 94e9b4b2b4ee30948cf4b437ad7d5ee70b4203bd Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 14 Jun 2026 20:32:25 +0100 Subject: [PATCH] Add CultNet transport profile contract --- docs/cultnet-transport-parity.md | 157 ++++++++++++++++++ packages/cultcache-py/PARITY.md | 8 +- .../cultcache-py/src/cultcache_py/verify.py | 4 + .../cultcache-py/src/cultnet_py/__init__.py | 8 + .../cultcache-py/src/cultnet_py/messages.py | 62 +++++++ .../src/cultnet_py/schema_catalog.py | 95 +++++++++++ packages/cultcache-py/tests/test_cultcache.py | 76 ++++++++- .../cultnet.transport-profile.schema.json | 80 +++++++++ packages/cultnet-rs/src/schema_discovery.rs | 7 + packages/cultnet-rs/tests/cultnet.rs | 15 ++ .../cultnet.transport-profile.schema.json | 80 +++++++++ packages/cultnet-ts/src/contracts.ts | 28 ++++ packages/cultnet-ts/src/schema-discovery.ts | 8 + packages/cultnet-ts/test/cultnet.test.ts | 6 + .../cultnet.transport-profile.schema.json | 80 +++++++++ .../CultNetSchemaRegistry.cs | 7 + .../NetworkingTests.cs | 8 + 17 files changed, 727 insertions(+), 2 deletions(-) create mode 100644 docs/cultnet-transport-parity.md create mode 100644 packages/cultnet-rs/contracts/cultnet.transport-profile.schema.json create mode 100644 packages/cultnet-ts/contracts/cultnet.transport-profile.schema.json create mode 100644 src/GameCult.Networking/Contracts/cultnet.transport-profile.schema.json diff --git a/docs/cultnet-transport-parity.md b/docs/cultnet-transport-parity.md new file mode 100644 index 00000000..e178419a --- /dev/null +++ b/docs/cultnet-transport-parity.md @@ -0,0 +1,157 @@ +# CultNet Transport Parity Map + +CultNet payload parity is not enough. The runtimes can already trade schema-v0 +MessagePack documents through the interop lane, but realtime networking still +has one real owner: the C# `GameCult.Networking` stack on LiteNetLib. The other +runtimes mostly speak a TCP-framed dialect for tests and local services. + +This document is the working map for promoting transport into CultNet itself so +C#, TypeScript, Rust, Python, Kotlin, and future runtimes can speak the same +network language. + +## Objective + +CultNet owns cross-runtime transport semantics: + +- peers discover each other without runtime-specific side channels; +- connection/session lifecycle is described the same way in every runtime; +- reliable ordered delivery is portable and testable across runtimes; +- unreliable and sequenced realtime channels can exist beside reliable state + sync without changing payload contracts; +- LiteNetLib becomes one adapter, not the hidden specification. + +## Current Mechanism + +| Runtime | Current data transport | Discovery | Transport authority today | +| --- | --- | --- | --- | +| C# `GameCult.Networking` | LiteNetLib UDP `NetManager` / `NetPeer`; sends legacy union messages and schema-v0 messages with `DeliveryMethod.ReliableOrdered` | LiteNetLib connection requests and app-level peer/catalog surfaces | C# owns the production-shaped realtime semantics | +| C# interop peer | TCP stream with 4-byte length-prefixed MessagePack frames | UDP multicast probe/announce | Test harness only | +| TypeScript `cultnet-ts` | `CultNetPeer` over any Node `Duplex`; interop uses TCP | UDP multicast probe/announce in the interop peer | Byte-stream abstraction; no UDP reliability owner | +| Rust `cultnet-rs` | Interop example uses TCP length-prefixed MessagePack frames | UDP multicast probe/announce | Example harness; no UDP reliability owner | +| Python `cultcache-py` | TCP sockets with 4-byte length-prefixed MessagePack frames for local CultMesh/CultNet server and client | Endpoint lists and CultMesh peer/Verse catalogs | Local framed service body; no UDP reliability owner | +| Kotlin `cultmesh-kotlin` | Minimal WebSocket-like lane over TCP socket | None found in the live package | Thin client surface | + +The live split is therefore: payload language is converging, transport language +is not. + +## Invariants + +- `cultnet.schema.v0` payloads are transport-neutral. Message schemas must not + know whether they rode TCP, LiteNetLib, WebSocket, or CultNet reliable UDP. +- Discovery is its own concern. A discovery packet may advertise transport + endpoints and capabilities, but it does not own reliable delivery semantics. +- The first portable realtime channel must provide reliable ordered delivery, + because schema catalogs, snapshots, shard logs, auth/session messages, and + mutation forwarding already depend on ordered request/response behavior. +- Transport capability negotiation must happen before higher-level CultMesh + state claims a peer is usable for realtime work. +- Auth/session primitives bind to a connection, but credential encryption and + session token formats remain owned by CultNet security, not by UDP packet + framing. +- Backpressure, resend pressure, MTU limits, fragmentation, ping/timeout, and + reconnect posture are transport-owned signals. They must not leak upward as + ad hoc message-level retry loops. + +## Intended Ownership + +`cultnet.transport.v0` owns the transport contract: + +- endpoint description: protocol, host, port, path/group when relevant; +- channel descriptions: reliable ordered, reliable unordered, sequenced, and + unreliable; +- packet envelope: connection id, sequence/ack state, channel id, fragment id, + payload encoding, and payload bytes; +- session lifecycle: connect, accept/reject, disconnect reason, timeout, ping, + reconnect state, and feature negotiation; +- flow control: send queue limits, resend window, fragmentation limit, and + backpressure reporting. + +Adapters own socket mechanics: + +- `cultnet.transport.tcp_framed.v0`: existing 4-byte length-prefixed MessagePack + stream, reliable ordered only; +- `cultnet.transport.litenetlib.v0`: C# adapter preserving current + `GameCult.Networking` behavior while exposing CultNet channel semantics; +- `cultnet.transport.websocket.v0`: browser/mobile-friendly stream adapter, + reliable ordered only; +- `cultnet.transport.rudp.v0`: native cross-runtime reliable UDP transport, + replacing LiteNetLib as the portable realtime specification. + +Payload handlers own documents: + +- `cultnet.login.v0`, `cultnet.register.v0`, `cultnet.verify.v0`, and + `cultnet.login_success.v0` are security/session messages; +- `cultnet.schema_catalog_*`, snapshot, shard catalog, shard log, subscription, + and simulation messages remain schema-v0 payloads; +- no payload handler decides packet resend, channel ordering, or fragmentation. + +## Cut Line + +- Do not build a Python-only LiteNetLib clone. +- Do not let the TCP interop harness become the implied production transport. +- Do not hide transport capability in loose endpoint strings. +- Do not add message-level retry compensators for missing reliable UDP. +- Do not put UDP packet mechanics into CultMesh database/session code. +- Do not treat C# LiteNetLib behavior as optional folklore; mine it for the + required semantics, then express those semantics in CultNet-owned contracts. + +## First Portable Contract + +The first shippable slice should be a transport profile document that every +runtime can advertise through schema catalogs and hello/peer descriptors: + +```text +cultnet.transport_profile.v0 + runtimeId + transports[] + transportId + protocol + endpoint + wireContracts[] + channels[] + channelId + delivery + ordering + maxPayloadBytes + maxFragmentBytes +``` + +This is not the reliable UDP implementation. It is the shared language that +lets the runtimes stop guessing what kind of pipe a peer is offering. + +## Implementation Sequence + +1. Add `cultnet.transport_profile.v0` as a shared contract in C#, TS, Rust, and + Python catalogs. +2. Make every runtime advertise its current transport honestly: + `tcp_framed`, `litenetlib`, or `websocket`. +3. Add a cross-runtime transport-profile interop check so parody surfaces fail + loudly. +4. Introduce a narrow transport port in each runtime: + `connect`, `accept`, `send(channel, payload)`, `receive`, `close`, `stats`. +5. Put current TCP/LiteNetLib/WebSocket bodies behind that port. +6. Build `cultnet.transport.rudp.v0` once against the shared port and prove it + with C#/TS/Rust/Python ping, loss, reordering, fragmentation, reconnect, and + schema-message tests. + +## Reliable UDP Semantics To Lift From LiteNetLib + +The portable RUDP contract must cover at least: + +- connection handshake with application connection key support; +- peer id/session id binding; +- ping and timeout detection; +- reliable ordered channel delivery; +- unreliable channel delivery for realtime observations/input; +- sequenced delivery for latest-state streams; +- duplicate suppression; +- ack masks or equivalent selective acknowledgement; +- resend windows and retransmission timers; +- MTU-safe fragmentation and reassembly; +- disconnect reason propagation; +- bounded send queues and observable backpressure; +- deterministic wire fixtures for packet encode/decode in every runtime. + +Until those are runtime-owned CultNet contracts, the runtimes do not truly +"speak the same language"; they are only translating documents after somebody +else already solved the pipe. diff --git a/packages/cultcache-py/PARITY.md b/packages/cultcache-py/PARITY.md index e1541701..49442389 100644 --- a/packages/cultcache-py/PARITY.md +++ b/packages/cultcache-py/PARITY.md @@ -38,7 +38,9 @@ CultCache, CultNet, and CultMesh. It is evidence, not confetti. `CultNetRawClient.sync_schema_catalog(...)`. The catalog includes `cultnet.error.v0`, and the local server advertises that version in hello responses because oversized snapshots and other peer failures use that - contract on the wire. + contract on the wire. It also advertises the shared + `cultnet.transport_profile.v0` contract so Python peers can describe their + current transport honestly before the cross-runtime reliable UDP owner lands. - Python CultNet security/session primitives: `CultNetClientSecurityOptions`, `CultNetServerSecurityOptions`, and `CultNetSecret` mirror the C#/TS connection-key, server signing-secret, @@ -267,6 +269,10 @@ node --test packages\cultnet-ts\dist-test\test\interop\cultnet-interop.test.js - Python does not implement the full LiteNetLib client/server auth flow. It now owns the shared security/session primitives for helper parity, while the live transport harness still speaks the schema-v0 interop lane. +- Python does not implement cross-runtime reliable UDP yet. The current + transport ownership map lives in `docs/cultnet-transport-parity.md`; the next + coherent target is a CultNet-owned transport port and `rudp` adapter shared + across runtimes, not a Python-only LiteNetLib imitation. - Python does not implement the full C# daemon/server stack. Its current role is package runtime, raw interop peer, launchable local CultMesh endpoint, local CultMesh session facade, and typed state participant. diff --git a/packages/cultcache-py/src/cultcache_py/verify.py b/packages/cultcache-py/src/cultcache_py/verify.py index 235db025..7772b428 100644 --- a/packages/cultcache-py/src/cultcache_py/verify.py +++ b/packages/cultcache-py/src/cultcache_py/verify.py @@ -80,14 +80,18 @@ EXPECTED_EXPORTS: dict[str, tuple[str, ...]] = { "encode_frame", "encode_witness_artifact_bundle_payload", "hello", + "login", + "login_success", "parse_message", "read_frame", + "register", "schema_catalog_request", "schema_document_map", "shard_catalog_request", "shard_log_request", "simulation_observation", "snapshot_request", + "verify_session", "wire_message_schema_descriptors", "wire_message_schema_catalog", "wire_message_schema_json", diff --git a/packages/cultcache-py/src/cultnet_py/__init__.py b/packages/cultcache-py/src/cultnet_py/__init__.py index cdf6a573..bfc3add7 100644 --- a/packages/cultcache-py/src/cultnet_py/__init__.py +++ b/packages/cultcache-py/src/cultnet_py/__init__.py @@ -11,12 +11,16 @@ from .messages import ( document_put_raw, encode_witness_artifact_bundle_payload, hello, + login, + login_success, parse_message, + register, schema_catalog_request, shard_catalog_request, shard_log_request, simulation_observation, snapshot_request, + verify_session, witness_artifact_bundle, ) from .prediction import CultNetClientAuthorityScope @@ -123,14 +127,18 @@ __all__ = [ "encode_frame", "hello", "INTEROP_WIRE_CONTRACT", + "login", + "login_success", "parse_message", "read_frame", + "register", "schema_catalog_request", "schema_document_map", "shard_catalog_request", "shard_log_request", "simulation_observation", "snapshot_request", + "verify_session", "wire_message_schema_descriptors", "wire_message_schema_catalog", "wire_message_schema_json", diff --git a/packages/cultcache-py/src/cultnet_py/messages.py b/packages/cultcache-py/src/cultnet_py/messages.py index 3b8232b8..2735589b 100644 --- a/packages/cultcache-py/src/cultnet_py/messages.py +++ b/packages/cultcache-py/src/cultnet_py/messages.py @@ -49,6 +49,68 @@ def hello( ) +def login( + *, + nonce: str, + auth: str, + password: str, +) -> CultNetMessage: + return CultNetMessage( + "cultnet.login.v0", + { + "nonce": nonce, + "auth": auth, + "password": password, + }, + ) + + +def register( + *, + nonce: str, + email: str, + password: str, + name: str, +) -> CultNetMessage: + return CultNetMessage( + "cultnet.register.v0", + { + "nonce": nonce, + "email": email, + "password": password, + "name": name, + }, + ) + + +def verify_session( + *, + nonce: str, + session: str, +) -> CultNetMessage: + return CultNetMessage( + "cultnet.verify.v0", + { + "nonce": nonce, + "session": session, + }, + ) + + +def login_success( + *, + nonce: str, + session: str, +) -> CultNetMessage: + return CultNetMessage( + "cultnet.login_success.v0", + { + "nonce": nonce, + "session": session, + }, + ) + + def document_put_raw( *, message_id: str = "", diff --git a/packages/cultcache-py/src/cultnet_py/schema_catalog.py b/packages/cultcache-py/src/cultnet_py/schema_catalog.py index e91c573e..f2e77476 100644 --- a/packages/cultcache-py/src/cultnet_py/schema_catalog.py +++ b/packages/cultcache-py/src/cultnet_py/schema_catalog.py @@ -9,10 +9,16 @@ INTEROP_WIRE_CONTRACT = "cultnet.schema.v0" CULTNET_SCHEMA_BASE = "https://github.com/GameCult/cultnet-ts/contracts" VERSE_CATALOG_REQUEST = "cultmesh.verse_catalog_request.v0" PEER_EXCHANGE_REQUEST = "cultmesh.peer_exchange_request.v0" +TRANSPORT_PROFILE_SCHEMA_VERSION = "cultnet.transport_profile.v0" +TRANSPORT_PROFILE_SCHEMA_ID = f"{CULTNET_SCHEMA_BASE}/cultnet.transport-profile.schema.json" WIRE_MESSAGE_SCHEMA_VERSIONS = ( ("cultnet.hello.v0", "CultNet Hello Message", f"{CULTNET_SCHEMA_BASE}/cultnet.hello.schema.json"), ("cultnet.error.v0", "CultNet Error Message", f"{CULTNET_SCHEMA_BASE}/cultnet.error.schema.json"), + ("cultnet.login.v0", "CultNet Login Message", f"{CULTNET_SCHEMA_BASE}/cultnet.login.schema.json"), + ("cultnet.register.v0", "CultNet Register Message", f"{CULTNET_SCHEMA_BASE}/cultnet.register.schema.json"), + ("cultnet.verify.v0", "CultNet Verify Message", f"{CULTNET_SCHEMA_BASE}/cultnet.verify.schema.json"), + ("cultnet.login_success.v0", "CultNet Login Success Message", f"{CULTNET_SCHEMA_BASE}/cultnet.login-success.schema.json"), ("cultnet.document_delete.v0", "CultNet Document Delete Message", f"{CULTNET_SCHEMA_BASE}/cultnet.document-delete.schema.json"), ("cultnet.document_put_raw.v0", "CultNet Raw Document Put Message", f"{CULTNET_SCHEMA_BASE}/cultnet.document-put-raw.schema.json"), ("cultnet.snapshot_request.v0", "CultNet Snapshot Request Message", f"{CULTNET_SCHEMA_BASE}/cultnet.snapshot-request.schema.json"), @@ -34,6 +40,10 @@ WIRE_MESSAGE_SCHEMA_VERSIONS = ( ("cultmesh.peer_exchange_response.v0", "CultMesh Peer Exchange Response Message", f"{CULTNET_SCHEMA_BASE}/cultmesh.peer-exchange-response.schema.json"), ) +SHARED_CONTRACT_SCHEMA_VERSIONS = ( + (TRANSPORT_PROFILE_SCHEMA_VERSION, "CultNet Transport Profile", TRANSPORT_PROFILE_SCHEMA_ID), +) + @dataclass(frozen=True) class CultNetSchemaDescriptor: @@ -154,6 +164,19 @@ def wire_message_schema_descriptors(include_schema_json: bool) -> list[dict[str, if include_schema_json: descriptor["schemaJson"] = schema_json descriptors.append(descriptor) + for schema_version, title, schema_id in SHARED_CONTRACT_SCHEMA_VERSIONS: + schema_json = shared_contract_schema_json(schema_id, title, schema_version) + descriptor = { + "schemaId": schema_id, + "kind": "shared_contract", + "schemaVersion": schema_version, + "title": title, + "wireContracts": [INTEROP_WIRE_CONTRACT], + "contentHash": hashlib.sha256(schema_json.encode("utf-8")).hexdigest(), + } + if include_schema_json: + descriptor["schemaJson"] = schema_json + descriptors.append(descriptor) return descriptors @@ -180,10 +203,63 @@ def wire_message_schema_json(schema_id: str, title: str, schema_version: str) -> }, separators=(",", ":"), sort_keys=True) +def shared_contract_schema_json(schema_id: str, title: str, schema_version: str) -> str: + if schema_version != TRANSPORT_PROFILE_SCHEMA_VERSION: + raise ValueError(f"unsupported shared contract schema version {schema_version}") + return json.dumps({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": schema_id, + "title": title, + "type": "object", + "required": ["schemaVersion", "runtimeId", "transports"], + "additionalProperties": False, + "properties": { + "schemaVersion": {"const": TRANSPORT_PROFILE_SCHEMA_VERSION}, + "runtimeId": {"type": "string", "minLength": 1}, + "transports": { + "type": "array", + "items": { + "type": "object", + "required": ["transportId", "protocol", "channels"], + "additionalProperties": False, + "properties": { + "transportId": {"type": "string", "minLength": 1}, + "protocol": {"type": "string", "enum": ["tcp_framed", "litenetlib", "websocket", "rudp"]}, + "host": {"type": "string", "minLength": 1}, + "port": {"type": "integer", "minimum": 1, "maximum": 65535}, + "path": {"type": "string", "minLength": 1}, + "discoveryGroup": {"type": "string", "minLength": 1}, + "wireContracts": {"type": "array", "items": {"type": "string", "minLength": 1}}, + "channels": { + "type": "array", + "items": { + "type": "object", + "required": ["channelId", "delivery", "ordering"], + "additionalProperties": False, + "properties": { + "channelId": {"type": "string", "minLength": 1}, + "delivery": {"type": "string", "enum": ["reliable", "unreliable"]}, + "ordering": {"type": "string", "enum": ["ordered", "unordered", "sequenced"]}, + "maxPayloadBytes": {"type": "integer", "minimum": 1}, + "maxFragmentBytes": {"type": "integer", "minimum": 1}, + }, + }, + }, + }, + }, + }, + }, + }, separators=(",", ":"), sort_keys=True) + + def wire_message_required_fields(schema_version: str) -> list[str]: required = { "cultnet.hello.v0": ["schemaVersion", "runtimeId"], "cultnet.error.v0": ["schemaVersion", "error"], + "cultnet.login.v0": ["schemaVersion", "nonce", "auth", "password"], + "cultnet.register.v0": ["schemaVersion", "nonce", "email", "password", "name"], + "cultnet.verify.v0": ["schemaVersion", "nonce", "session"], + "cultnet.login_success.v0": ["schemaVersion", "nonce", "session"], "cultnet.document_delete.v0": ["schemaVersion", "messageId", "schemaId", "recordKey"], "cultnet.document_put_raw.v0": ["schemaVersion", "messageId", "document"], "cultnet.snapshot_request.v0": ["schemaVersion", "messageId"], @@ -264,6 +340,25 @@ def wire_message_schema_properties(schema_version: str) -> dict[str, Any]: "code": {"type": "string"}, "details": {"type": "object", "additionalProperties": True}, }, + "cultnet.login.v0": { + "nonce": {"type": "string", "minLength": 1}, + "auth": {"type": "string", "minLength": 1}, + "password": {"type": "string", "minLength": 1}, + }, + "cultnet.register.v0": { + "nonce": {"type": "string", "minLength": 1}, + "email": {"type": "string", "minLength": 1}, + "password": {"type": "string", "minLength": 1}, + "name": {"type": "string", "minLength": 1}, + }, + "cultnet.verify.v0": { + "nonce": {"type": "string", "minLength": 1}, + "session": {"type": "string", "minLength": 1}, + }, + "cultnet.login_success.v0": { + "nonce": {"type": "string", "minLength": 1}, + "session": {"type": "string", "minLength": 1}, + }, "cultnet.document_delete.v0": { **common, }, diff --git a/packages/cultcache-py/tests/test_cultcache.py b/packages/cultcache-py/tests/test_cultcache.py index 9e3d8878..280dc76b 100644 --- a/packages/cultcache-py/tests/test_cultcache.py +++ b/packages/cultcache-py/tests/test_cultcache.py @@ -79,13 +79,17 @@ from cultnet_py import ( encode_frame, encode_witness_artifact_bundle_payload, hello, + login, + login_success, parse_message, + register, schema_catalog_request, schema_document_map, shard_catalog_request, shard_log_request, simulation_observation, snapshot_request, + verify_session, wire_message_schema_catalog, wire_message_schema_descriptors, witness_artifact_bundle, @@ -1175,11 +1179,58 @@ class CultCacheTests(unittest.TestCase): self.assertEqual(message["shardId"], "interop") self.assertEqual(message["shardEpoch"], 1) + def test_cultnet_auth_session_helpers_match_schema_v0_shape(self) -> None: + self.assertEqual( + login(nonce="nonce", auth="encrypted-auth", password="encrypted-password").to_wire(), + { + "schemaVersion": "cultnet.login.v0", + "nonce": "nonce", + "auth": "encrypted-auth", + "password": "encrypted-password", + }, + ) + self.assertEqual( + register( + nonce="nonce", + email="encrypted-email", + password="encrypted-password", + name="encrypted-name", + ).to_wire(), + { + "schemaVersion": "cultnet.register.v0", + "nonce": "nonce", + "email": "encrypted-email", + "password": "encrypted-password", + "name": "encrypted-name", + }, + ) + self.assertEqual( + verify_session(nonce="nonce", session="encrypted-session").to_wire(), + { + "schemaVersion": "cultnet.verify.v0", + "nonce": "nonce", + "session": "encrypted-session", + }, + ) + self.assertEqual( + login_success(nonce="nonce", session="encrypted-session").to_wire(), + { + "schemaVersion": "cultnet.login_success.v0", + "nonce": "nonce", + "session": "encrypted-session", + }, + ) + def test_cultnet_wire_catalog_describes_python_handled_messages(self) -> None: descriptors = wire_message_schema_descriptors(include_schema_json=True) by_version = {descriptor["schemaVersion"]: descriptor for descriptor in descriptors} for schema_version in [ "cultnet.error.v0", + "cultnet.login.v0", + "cultnet.register.v0", + "cultnet.verify.v0", + "cultnet.login_success.v0", + "cultnet.transport_profile.v0", "cultnet.document_delete.v0", "cultnet.database_change_raw.v0", "cultnet.shard_log_response.v0", @@ -1187,7 +1238,8 @@ class CultCacheTests(unittest.TestCase): "cultmesh.peer_exchange_response.v0", ]: self.assertIn(schema_version, by_version) - self.assertEqual(by_version[schema_version]["kind"], "wire_message") + expected_kind = "shared_contract" if schema_version == "cultnet.transport_profile.v0" else "wire_message" + self.assertEqual(by_version[schema_version]["kind"], expected_kind) self.assertIn("cultnet.schema.v0", by_version[schema_version]["wireContracts"]) self.assertIn(schema_version, by_version[schema_version]["schemaJson"]) self.assertEqual(len(by_version[schema_version]["contentHash"]), 64) @@ -1204,6 +1256,28 @@ class CultCacheTests(unittest.TestCase): self.assertEqual(error_schema["properties"]["error"]["type"], "string") self.assertIn("details", error_schema["properties"]) + login_schema = json.loads(by_version["cultnet.login.v0"]["schemaJson"]) + self.assertEqual(login_schema["required"], ["schemaVersion", "nonce", "auth", "password"]) + self.assertEqual(login_schema["properties"]["auth"]["type"], "string") + + register_schema = json.loads(by_version["cultnet.register.v0"]["schemaJson"]) + self.assertEqual(register_schema["required"], ["schemaVersion", "nonce", "email", "password", "name"]) + self.assertEqual(register_schema["properties"]["email"]["type"], "string") + + verify_schema = json.loads(by_version["cultnet.verify.v0"]["schemaJson"]) + self.assertEqual(verify_schema["required"], ["schemaVersion", "nonce", "session"]) + self.assertEqual(verify_schema["properties"]["session"]["type"], "string") + + success_schema = json.loads(by_version["cultnet.login_success.v0"]["schemaJson"]) + self.assertEqual(success_schema["required"], ["schemaVersion", "nonce", "session"]) + + transport_profile_schema = json.loads(by_version["cultnet.transport_profile.v0"]["schemaJson"]) + self.assertEqual(transport_profile_schema["required"], ["schemaVersion", "runtimeId", "transports"]) + transport = transport_profile_schema["properties"]["transports"]["items"] + self.assertEqual(transport["properties"]["protocol"]["enum"], ["tcp_framed", "litenetlib", "websocket", "rudp"]) + channel = transport["properties"]["channels"]["items"] + self.assertEqual(channel["properties"]["ordering"]["enum"], ["ordered", "unordered", "sequenced"]) + change_schema = json.loads(by_version["cultnet.database_change_raw.v0"]["schemaJson"]) self.assertEqual(change_schema["properties"]["changeKind"]["enum"], ["added", "updated", "removed"]) self.assertIn("document", change_schema["properties"]) diff --git a/packages/cultnet-rs/contracts/cultnet.transport-profile.schema.json b/packages/cultnet-rs/contracts/cultnet.transport-profile.schema.json new file mode 100644 index 00000000..25763af6 --- /dev/null +++ b/packages/cultnet-rs/contracts/cultnet.transport-profile.schema.json @@ -0,0 +1,80 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.transport-profile.schema.json", + "title": "CultNet Transport Profile", + "type": "object", + "required": [ + "schemaVersion", + "runtimeId", + "transports" + ], + "additionalProperties": false, + "properties": { + "schemaVersion": { "const": "cultnet.transport_profile.v0" }, + "runtimeId": { "type": "string", "minLength": 1 }, + "transports": { + "type": "array", + "items": { + "type": "object", + "required": [ + "transportId", + "protocol", + "channels" + ], + "additionalProperties": false, + "properties": { + "transportId": { "type": "string", "minLength": 1 }, + "protocol": { + "type": "string", + "enum": [ + "tcp_framed", + "litenetlib", + "websocket", + "rudp" + ] + }, + "host": { "type": "string", "minLength": 1 }, + "port": { "type": "integer", "minimum": 1, "maximum": 65535 }, + "path": { "type": "string", "minLength": 1 }, + "discoveryGroup": { "type": "string", "minLength": 1 }, + "wireContracts": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + }, + "channels": { + "type": "array", + "items": { + "type": "object", + "required": [ + "channelId", + "delivery", + "ordering" + ], + "additionalProperties": false, + "properties": { + "channelId": { "type": "string", "minLength": 1 }, + "delivery": { + "type": "string", + "enum": [ + "reliable", + "unreliable" + ] + }, + "ordering": { + "type": "string", + "enum": [ + "ordered", + "unordered", + "sequenced" + ] + }, + "maxPayloadBytes": { "type": "integer", "minimum": 1 }, + "maxFragmentBytes": { "type": "integer", "minimum": 1 } + } + } + } + } + } + } + } +} diff --git a/packages/cultnet-rs/src/schema_discovery.rs b/packages/cultnet-rs/src/schema_discovery.rs index 15ea9fb9..2cc14200 100644 --- a/packages/cultnet-rs/src/schema_discovery.rs +++ b/packages/cultnet-rs/src/schema_discovery.rs @@ -207,6 +207,13 @@ pub fn builtin_schema_registry() -> Result { None, None, )?, + schema_registration( + include_str!("../contracts/cultnet.transport-profile.schema.json"), + CultNetSchemaKind::SharedContract, + vec![CultNetWireContract::CultNetSchemaV0], + Some("cultnet.transport_profile.v0"), + None, + )?, schema_registration( include_str!("../contracts/cultnet.login.schema.json"), CultNetSchemaKind::WireMessage, diff --git a/packages/cultnet-rs/tests/cultnet.rs b/packages/cultnet-rs/tests/cultnet.rs index 347b3132..d4d12edd 100644 --- a/packages/cultnet-rs/tests/cultnet.rs +++ b/packages/cultnet-rs/tests/cultnet.rs @@ -328,6 +328,21 @@ fn builtin_schema_registry_advertises_canonical_ghostlight_schema_without_inline ); assert_eq!(ghostlight.schema_json, None); assert!(!ghostlight.content_hash.is_empty()); + + let transport_profile = schemas + .iter() + .find(|schema| schema.schema_version.as_deref() == Some("cultnet.transport_profile.v0")) + .expect("transport profile schema is advertised"); + + assert_eq!(transport_profile.kind, CultNetSchemaKind::SharedContract); + assert_eq!( + transport_profile.schema_id, + "https://github.com/GameCult/cultnet-ts/contracts/cultnet.transport-profile.schema.json" + ); + assert_eq!( + transport_profile.wire_contracts, + vec![CultNetWireContract::CultNetSchemaV0] + ); Ok(()) } diff --git a/packages/cultnet-ts/contracts/cultnet.transport-profile.schema.json b/packages/cultnet-ts/contracts/cultnet.transport-profile.schema.json new file mode 100644 index 00000000..25763af6 --- /dev/null +++ b/packages/cultnet-ts/contracts/cultnet.transport-profile.schema.json @@ -0,0 +1,80 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.transport-profile.schema.json", + "title": "CultNet Transport Profile", + "type": "object", + "required": [ + "schemaVersion", + "runtimeId", + "transports" + ], + "additionalProperties": false, + "properties": { + "schemaVersion": { "const": "cultnet.transport_profile.v0" }, + "runtimeId": { "type": "string", "minLength": 1 }, + "transports": { + "type": "array", + "items": { + "type": "object", + "required": [ + "transportId", + "protocol", + "channels" + ], + "additionalProperties": false, + "properties": { + "transportId": { "type": "string", "minLength": 1 }, + "protocol": { + "type": "string", + "enum": [ + "tcp_framed", + "litenetlib", + "websocket", + "rudp" + ] + }, + "host": { "type": "string", "minLength": 1 }, + "port": { "type": "integer", "minimum": 1, "maximum": 65535 }, + "path": { "type": "string", "minLength": 1 }, + "discoveryGroup": { "type": "string", "minLength": 1 }, + "wireContracts": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + }, + "channels": { + "type": "array", + "items": { + "type": "object", + "required": [ + "channelId", + "delivery", + "ordering" + ], + "additionalProperties": false, + "properties": { + "channelId": { "type": "string", "minLength": 1 }, + "delivery": { + "type": "string", + "enum": [ + "reliable", + "unreliable" + ] + }, + "ordering": { + "type": "string", + "enum": [ + "ordered", + "unordered", + "sequenced" + ] + }, + "maxPayloadBytes": { "type": "integer", "minimum": 1 }, + "maxFragmentBytes": { "type": "integer", "minimum": 1 } + } + } + } + } + } + } + } +} diff --git a/packages/cultnet-ts/src/contracts.ts b/packages/cultnet-ts/src/contracts.ts index 199a0b90..aaabf3f5 100644 --- a/packages/cultnet-ts/src/contracts.ts +++ b/packages/cultnet-ts/src/contracts.ts @@ -14,6 +14,7 @@ import documentDeleteSchema from "../contracts/cultnet.document-delete.schema.js import rawDocumentRecordSchema from "../contracts/cultnet.raw-document-record.schema.json"; import documentPutRawSchema from "../contracts/cultnet.document-put-raw.schema.json"; import documentMutationContractSchema from "../contracts/cultnet.document-mutation-contract.schema.json"; +import transportProfileSchema from "../contracts/cultnet.transport-profile.schema.json"; import snapshotRequestSchema from "../contracts/cultnet.snapshot-request.schema.json"; import snapshotResponseSchema from "../contracts/cultnet.snapshot-response.schema.json"; import snapshotResponseRawSchema from "../contracts/cultnet.snapshot-response-raw.schema.json"; @@ -55,6 +56,31 @@ export interface CultNetDocumentMutationContract { notes?: string[]; } +export interface CultNetTransportProfile { + schemaVersion: "cultnet.transport_profile.v0"; + runtimeId: string; + transports: CultNetTransportDescriptor[]; +} + +export interface CultNetTransportDescriptor { + transportId: string; + protocol: "tcp_framed" | "litenetlib" | "websocket" | "rudp"; + host?: string; + port?: number; + path?: string; + discoveryGroup?: string; + wireContracts?: string[]; + channels: CultNetTransportChannel[]; +} + +export interface CultNetTransportChannel { + channelId: string; + delivery: "reliable" | "unreliable"; + ordering: "ordered" | "unordered" | "sequenced"; + maxPayloadBytes?: number; + maxFragmentBytes?: number; +} + export interface CultNetDocumentRecord { schemaId: string; recordKey: string; @@ -471,6 +497,7 @@ for (const schema of [ documentRecordSchema, rawDocumentRecordSchema, documentMutationContractSchema, + transportProfileSchema, schemaDescriptorSchema, ...CULTNET_MESSAGE_SCHEMAS, ]) { @@ -979,6 +1006,7 @@ export const cultNetSchemas = { rawDocumentRecordSchema, documentPutRawSchema, documentMutationContractSchema, + transportProfileSchema, snapshotRequestSchema, snapshotResponseSchema, snapshotResponseRawSchema, diff --git a/packages/cultnet-ts/src/schema-discovery.ts b/packages/cultnet-ts/src/schema-discovery.ts index 03f52309..61a92aa7 100644 --- a/packages/cultnet-ts/src/schema-discovery.ts +++ b/packages/cultnet-ts/src/schema-discovery.ts @@ -137,6 +137,14 @@ export const cultNetBuiltinSchemaRegistry = new CultNetSchemaRegistry([ title: cultNetSchemas.helloSchema.title, wireContracts: ["cultnet.schema.v0"], }, + { + schemaId: cultNetSchemas.transportProfileSchema.$id, + kind: "shared_contract", + schema: cultNetSchemas.transportProfileSchema, + schemaVersion: "cultnet.transport_profile.v0", + title: cultNetSchemas.transportProfileSchema.title, + wireContracts: ["cultnet.schema.v0"], + }, { schemaId: cultNetSchemas.loginSchema.$id, kind: "wire_message", diff --git a/packages/cultnet-ts/test/cultnet.test.ts b/packages/cultnet-ts/test/cultnet.test.ts index d1986797..e396250c 100644 --- a/packages/cultnet-ts/test/cultnet.test.ts +++ b/packages/cultnet-ts/test/cultnet.test.ts @@ -186,6 +186,12 @@ test("CultNet schema discovery catalog can advertise canonical schemas without i assert.equal(ghostlight?.documentType, "ghostlight.agent-state"); assert.equal(typeof ghostlight?.contentHash, "string"); assert.equal(ghostlight?.schemaJson, undefined); + + const transportProfile = response.schemas.find((schema) => schema.schemaVersion === "cultnet.transport_profile.v0"); + assert.ok(transportProfile); + assert.equal(transportProfile?.kind, "shared_contract"); + assert.equal(transportProfile?.schemaId, cultNetSchemas.transportProfileSchema.$id); + assert.deepEqual(transportProfile?.wireContracts, ["cultnet.schema.v0"]); }); test("CultNet schema discovery can round-trip over the legacy wire contract when schemas are requested inline", () => { diff --git a/src/GameCult.Networking/Contracts/cultnet.transport-profile.schema.json b/src/GameCult.Networking/Contracts/cultnet.transport-profile.schema.json new file mode 100644 index 00000000..25763af6 --- /dev/null +++ b/src/GameCult.Networking/Contracts/cultnet.transport-profile.schema.json @@ -0,0 +1,80 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.transport-profile.schema.json", + "title": "CultNet Transport Profile", + "type": "object", + "required": [ + "schemaVersion", + "runtimeId", + "transports" + ], + "additionalProperties": false, + "properties": { + "schemaVersion": { "const": "cultnet.transport_profile.v0" }, + "runtimeId": { "type": "string", "minLength": 1 }, + "transports": { + "type": "array", + "items": { + "type": "object", + "required": [ + "transportId", + "protocol", + "channels" + ], + "additionalProperties": false, + "properties": { + "transportId": { "type": "string", "minLength": 1 }, + "protocol": { + "type": "string", + "enum": [ + "tcp_framed", + "litenetlib", + "websocket", + "rudp" + ] + }, + "host": { "type": "string", "minLength": 1 }, + "port": { "type": "integer", "minimum": 1, "maximum": 65535 }, + "path": { "type": "string", "minLength": 1 }, + "discoveryGroup": { "type": "string", "minLength": 1 }, + "wireContracts": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + }, + "channels": { + "type": "array", + "items": { + "type": "object", + "required": [ + "channelId", + "delivery", + "ordering" + ], + "additionalProperties": false, + "properties": { + "channelId": { "type": "string", "minLength": 1 }, + "delivery": { + "type": "string", + "enum": [ + "reliable", + "unreliable" + ] + }, + "ordering": { + "type": "string", + "enum": [ + "ordered", + "unordered", + "sequenced" + ] + }, + "maxPayloadBytes": { "type": "integer", "minimum": 1 }, + "maxFragmentBytes": { "type": "integer", "minimum": 1 } + } + } + } + } + } + } + } +} diff --git a/src/GameCult.Networking/CultNetSchemaRegistry.cs b/src/GameCult.Networking/CultNetSchemaRegistry.cs index 10f216ec..baa20b5b 100644 --- a/src/GameCult.Networking/CultNetSchemaRegistry.cs +++ b/src/GameCult.Networking/CultNetSchemaRegistry.cs @@ -266,6 +266,13 @@ namespace GameCult.Networking WireContracts = new[] { CultNetWireContracts.SchemaV0 } }, new SchemaResourceSpec + { + FileName = "cultnet.transport-profile.schema.json", + Kind = "shared_contract", + SchemaVersion = "cultnet.transport_profile.v0", + WireContracts = new[] { CultNetWireContracts.SchemaV0 } + }, + new SchemaResourceSpec { FileName = "cultnet.login.schema.json", Kind = "wire_message", diff --git a/tests/GameCult.Networking.Tests/NetworkingTests.cs b/tests/GameCult.Networking.Tests/NetworkingTests.cs index 65831af5..42b8b675 100644 --- a/tests/GameCult.Networking.Tests/NetworkingTests.cs +++ b/tests/GameCult.Networking.Tests/NetworkingTests.cs @@ -2323,6 +2323,8 @@ namespace GameCult.Networking.Tests schema => schema.SchemaVersion == CultNetSchemaVersions.SnapshotResponseRaw); var ghostlight = Array.Find(response.Schemas, schema => schema.SchemaVersion == "ghostlight.agent_state.v0"); + var transportProfile = Array.Find(response.Schemas, + schema => schema.SchemaVersion == "cultnet.transport_profile.v0"); Assert.That(rawPut, Is.Not.Null); Assert.That(rawPut!.WireContracts, Is.EqualTo([CultNetWireContracts.SchemaV0])); @@ -2335,6 +2337,12 @@ namespace GameCult.Networking.Tests Assert.That(ghostlight, Is.Not.Null); Assert.That(ghostlight!.DocumentType, Is.EqualTo("ghostlight.agent-state")); Assert.That(ghostlight.Kind, Is.EqualTo("document_payload")); + + Assert.That(transportProfile, Is.Not.Null); + Assert.That(transportProfile!.Kind, Is.EqualTo("shared_contract")); + Assert.That(transportProfile.WireContracts, Is.EqualTo([CultNetWireContracts.SchemaV0])); + Assert.That(transportProfile.SchemaJson, Does.Contain("cultnet.transport_profile.v0")); + Assert.That(transportProfile.SchemaJson, Does.Contain("litenetlib")); } [Test]