Add CultNet transport profile contract

This commit is contained in:
Meta Vens 2026-06-14 20:32:25 +01:00
commit 94e9b4b2b4
17 changed files with 727 additions and 2 deletions

View 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.

View file

@ -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.

View file

@ -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",

View file

@ -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",

View file

@ -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 = "",

View file

@ -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,
},

View file

@ -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"])

View file

@ -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 }
}
}
}
}
}
}
}
}

View file

@ -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,

View file

@ -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(())
}

View file

@ -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 }
}
}
}
}
}
}
}
}

View file

@ -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,

View file

@ -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",

View file

@ -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", () => {

View file

@ -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 }
}
}
}
}
}
}
}
}

View file

@ -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",

View file

@ -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]