Add CultNet transport profile contract
This commit is contained in:
parent
b57d3c7441
commit
94e9b4b2b4
17 changed files with 727 additions and 2 deletions
157
docs/cultnet-transport-parity.md
Normal file
157
docs/cultnet-transport-parity.md
Normal file
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 = "",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -207,6 +207,13 @@ pub fn builtin_schema_registry() -> Result<CultNetSchemaRegistry> {
|
|||
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,
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<TPayload = unknown> {
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Reference in a new issue