docs(start): tested CultNet first-call snippets (C#, TypeScript, Rust, Python) #11

Merged
libby merged 2 commits from libby/start-cultnet-cells into main 2026-11-07 12:10:56 +00:00
5 changed files with 549 additions and 4 deletions

View file

@ -16,10 +16,10 @@ this page.
| | CultCache | CultMath | Geometry | CultNet | CultMesh |
| --- | --- | --- | --- | --- | --- |
| **C#** | [first call, tested](#c-first-call) | [package](../../packages/cultmath/README.md); snippet not yet | [package](../../src/GameCult.Geometry/README.md); snippet not yet | [package](../../src/GameCult.Networking); snippet not yet | [quickstart](../../src/GameCult.Mesh/docs/durable-node-quickstart.md); run by `scripts/verify-eve-getting-started.ps1` |
| **TypeScript** | [first call, tested](#typescript-first-call) | not yet | not yet | [package](../../packages/cultnet-ts/README.md); snippet not yet | [package](../../packages/cultmesh-ts/README.md); snippet not yet; [recipe: two-client .io game](io-game.md), tested with two live clients, browser page not yet run |
| **Rust** | [first call, tested](#rust-first-call) | [native core](../../packages/cultmath/native/cultmath-core); snippet not yet | not yet | [package](../../packages/cultnet-rs/README.md), [examples](../../packages/cultnet-rs/examples); snippet not yet | [package](../../packages/cultmesh-rs); snippet not yet |
| **Python** | [first call, tested](#python-first-call) | not yet | not yet | [package](../../packages/cultnet-py/README.md); snippet not yet | [package](../../packages/cultmesh-py/README.md); snippet not yet |
| **C#** | [first call, tested](#c-first-call) | [package](../../packages/cultmath/README.md); snippet not yet | [package](../../src/GameCult.Geometry/README.md); snippet not yet | [first call, tested](#c-cultnet-first-call); [package](../../src/GameCult.Networking) | [quickstart](../../src/GameCult.Mesh/docs/durable-node-quickstart.md); run by `scripts/verify-eve-getting-started.ps1` |
| **TypeScript** | [first call, tested](#typescript-first-call) | not yet | not yet | [first call, tested](#typescript-cultnet-first-call); [package](../../packages/cultnet-ts/README.md) | [package](../../packages/cultmesh-ts/README.md); snippet not yet; [recipe: two-client .io game](io-game.md), tested with two live clients, browser page not yet run |
| **Rust** | [first call, tested](#rust-first-call) | [native core](../../packages/cultmath/native/cultmath-core); snippet not yet | not yet | [first call, tested](#rust-cultnet-first-call); [package](../../packages/cultnet-rs/README.md), [examples](../../packages/cultnet-rs/examples) | [package](../../packages/cultmesh-rs); snippet not yet |
| **Python** | [first call, tested](#python-first-call) | not yet | not yet | [first call, tested](#python-cultnet-first-call); [package](../../packages/cultnet-py/README.md) | [package](../../packages/cultmesh-py/README.md); snippet not yet |
For the whole
picture of what each piece is for, see the
@ -222,6 +222,244 @@ def first_call() -> Item:
return reopened.get_required(item_doc, "item:potion")
```
## CultNet first calls
CultNet is schema-v0 messages in length-prefixed frames. The first call in each runtime is the same
shape: define a typed document, build a `cultnet.document_put_raw.v0` message carrying its MessagePack
payload, send one frame over a loopback pipe, and apply the received message to a second cache. Each
block is verbatim from the test that runs it; a second test in the same file fails if the block stops
matching.
### C# CultNet first call
Source: [`tests/GameCult.Networking.Tests/CultNetFirstCallTests.cs`](../../tests/GameCult.Networking.Tests/CultNetFirstCallTests.cs),
run by `dotnet test tests/GameCult.Networking.Tests --filter CultNetFirstCallTests`. More: `src/GameCult.Networking/README.md`.
```csharp
using System.Net;
using System.Net.Sockets;
using System.Threading.Tasks;
using GameCult.Caching;
using GameCult.Networking;
using MessagePack;
[CultDocument("gamecult.first_call_note", "gamecult.first_call_note.v1")]
[MessagePackObject]
public sealed class Note
{
[Key(0)] [CultName] public string Title = string.Empty;
[Key(1)] public string Body = string.Empty;
}
public static class CultNetFirstCall
{
public static async Task<Note?> Run()
{
var inbox = new CultCache(); // the receiver's cache
var documents = new CultNetDocumentRegistry();
var received = new TaskCompletionSource<bool>();
// Receive: a loopback listener that applies each raw document put to the inbox.
using var server = new TcpFramedCultNetSchemaServer(new TcpListener(IPAddress.Loopback, 0));
server.OnCultNet<CultNetDocumentPutRawMessage>(async (put, peer) =>
{
await documents.ApplyRawDocumentPutMessageAsync<Note>(inbox, put);
received.TrySetResult(true);
});
// Send: a typed document as a cultnet.document_put_raw.v0 message.
using var client = new TcpFramedCultNetSchemaClient();
client.Connect("127.0.0.1", server.LocalEndPoint.Port);
var handle = new CultRecordHandle<Note>(new CultRecordKey("note:hello"));
client.SendCultNet(documents.CreateRawDocumentPutMessage(
"put-1", handle, new Note { Title = "Hello", Body = "sent over CultNet" }));
await received.Task.WaitAsync(TimeSpan.FromSeconds(5));
return inbox.Get<Note>(handle.Key);
}
}
```
### TypeScript CultNet first call
Source: [`packages/cultnet-ts/test/first-call.test.ts`](../../packages/cultnet-ts/test/first-call.test.ts), run by
`npm test` in `packages/cultnet-ts` (build `packages/cultcache-ts` first: `npm run build` there). The test imports
from `../src` where the block reads `cultnet-ts`. More: `packages/cultnet-ts/README.md`.
```ts
import { createServer, connect } from "node:net";
import { z } from "zod";
import { CultCache, defineDocumentType } from "@gamecult/cultcache-ts";
import {
CultNetDocumentRegistry,
createTcpFramedCultNetPeer,
defineCultNetDocumentBinding,
} from "cultnet-ts";
const noteDocument = defineDocumentType({
type: "note",
schemaId: "gamecult.first_call_note.v1",
schemaName: "gamecult.first_call_note",
schemaVersion: "gamecult.first_call_note.v1",
schema: z.object({ title: z.string(), body: z.string() }),
name: "title",
});
const documents = new CultNetDocumentRegistry([defineCultNetDocumentBinding({ definition: noteDocument })]);
async function firstCall() {
const options = { runtimeId: "first-call", wireContract: "cultnet.schema.v0" } as const;
const inbox = CultCache.builder().withDocumentType(noteDocument).build(); // the receiver's cache
// Receiver: a loopback listener whose peer applies each document put to the inbox.
const received = new Promise<void>((resolve, reject) => {
const server = createServer((socket) => {
const peer = createTcpFramedCultNetPeer(socket, options);
peer.on("invalidMessage", reject);
peer.on("message", async (message) => {
if (message.schemaVersion !== "cultnet.document_put_raw.v0") return;
await documents.applyRawDocumentPutMessage(inbox, message);
peer.close();
server.close();
resolve();
});
}).listen(0, "127.0.0.1", () => {
// Sender: connect, build a typed put message, send it.
const { port } = server.address() as { port: number };
const sender = createTcpFramedCultNetPeer(connect(port, "127.0.0.1"), options);
sender.send(
documents.createRawDocumentPutMessage(documents.get("note")!, "put-1", "note:hello", {
title: "Hello",
body: "sent over CultNet",
}),
);
received.finally(() => sender.close());
});
});
await received;
return inbox.get(noteDocument, "note:hello");
}
```
### Rust CultNet first call
Source: [`packages/cultnet-rs/tests/first_call.rs`](../../packages/cultnet-rs/tests/first_call.rs), run by
`cargo test --test first_call` in `packages/cultnet-rs`. More: `packages/cultnet-rs/README.md`.
```rust
use std::net::{TcpListener, TcpStream};
use std::thread;
use cultnet_rs::{
CultNetDocumentBinding, CultNetDocumentPutOptions, CultNetDocumentRegistry, CultNetWireContract,
TcpFramedTransportConnection, create_tcp_framed_transport_profile,
decode_cultnet_message_from_slice, encode_cultnet_message_to_vec,
};
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
#[cultcache(type = "note")]
struct Note {
#[cultcache(key = 0)]
title: String,
#[cultcache(key = 1)]
body: String,
}
fn first_call() -> anyhow::Result<Note> {
let mut documents = CultNetDocumentRegistry::new();
documents.register(CultNetDocumentBinding::for_entry_with_schema_id::<Note>(
"gamecult.first_call_note.v1".to_string(),
"gamecult.first_call_note.v1".to_string(),
))?;
let profile = |runtime_id| create_tcp_framed_transport_profile(runtime_id, Default::default());
// Receiver: a loopback listener that applies the document put it receives to its own cache.
let listener = TcpListener::bind("127.0.0.1:0")?;
let port = listener.local_addr()?.port();
let receiver = thread::spawn({
let (documents, profile) = (documents.clone(), profile("receiver"));
move || -> anyhow::Result<Note> {
let (stream, _) = listener.accept()?;
let frame = TcpFramedTransportConnection::new(stream, profile).receive()?;
let message = decode_cultnet_message_from_slice(&frame.payload, CultNetWireContract::CultNetSchemaV0)?;
let mut inbox = CultCache::new();
inbox.register_entry_type::<Note>()?;
documents.apply_raw_document_put_message::<Note>(&mut inbox, &message)
}
});
// Sender: build a typed put message, encode it for the wire, send one frame.
let put = documents.create_raw_document_put_message(
"put-1",
"note:hello",
&Note { title: "Hello".to_string(), body: "sent over CultNet".to_string() },
CultNetDocumentPutOptions::default(),
)?;
let bytes = encode_cultnet_message_to_vec(&put, CultNetWireContract::CultNetSchemaV0)?;
TcpFramedTransportConnection::new(TcpStream::connect(("127.0.0.1", port))?, profile("sender"))
.send("schema", &bytes)?;
receiver.join().expect("receiver thread")
}
```
### Python CultNet first call
Source: [`packages/cultnet-py/tests/test_first_call.py`](../../packages/cultnet-py/tests/test_first_call.py), run by
`python -m unittest tests.test_first_call` in `packages/cultnet-py` with both `src` and `../cultcache-py/src` on
`PYTHONPATH` and `msgpack` installed. The socket pair stands in for a TCP connection. More: `packages/cultnet-py/README.md`.
```python
import socket
from dataclasses import asdict, dataclass
from cultcache_py import CultCache, define_document_type
from cultnet_py import (
apply_raw_document_record,
document_put_raw,
encode_frame,
parse_message,
read_frame,
schema_document_map,
)
@dataclass
class Note:
title: str
body: str
note_doc = define_document_type(
"note",
encode=lambda note: asdict(note),
decode=lambda raw: Note(**raw),
name="title",
)
def first_call() -> Note:
sender, receiver = socket.socketpair() # an in-process pipe; use socket.create_connection for TCP
# Send: a typed document as a cultnet.document_put_raw.v0 message in one length-prefixed frame.
put = document_put_raw(
message_id="put-1",
key="note:hello",
schema_id=note_doc.catalog_entry().schema_id,
stored_at="2026-01-01T00:00:00Z",
payload=note_doc.encode_payload(Note("Hello", "sent over CultNet")),
)
sender.sendall(encode_frame(put.to_bytes()))
# Receive: read the frame, parse the message, apply its record to a second cache.
message = parse_message(read_frame(receiver.makefile("rb")))
assert message.schema_version == "cultnet.document_put_raw.v0"
inbox = CultCache.builder().register_document_type(note_doc).build()
apply_raw_document_record(inbox, schema_document_map([note_doc]), message.body["document"])
sender.close()
receiver.close()
return inbox.get_required(note_doc, "note:hello")
```
## Where next
- Smallest durable mesh path: `src/GameCult.Mesh/docs/durable-node-quickstart.md`.

View file

@ -0,0 +1,72 @@
"""The Python CultNet first call from docs/start/README.md. The docs show the region between the
`snippet` markers verbatim, and test_docs_show_the_tested_snippet fails when they differ."""
import unittest
from pathlib import Path
# snippet:begin
import socket
from dataclasses import asdict, dataclass
from cultcache_py import CultCache, define_document_type
from cultnet_py import (
apply_raw_document_record,
document_put_raw,
encode_frame,
parse_message,
read_frame,
schema_document_map,
)
@dataclass
class Note:
title: str
body: str
note_doc = define_document_type(
"note",
encode=lambda note: asdict(note),
decode=lambda raw: Note(**raw),
name="title",
)
def first_call() -> Note:
sender, receiver = socket.socketpair() # an in-process pipe; use socket.create_connection for TCP
# Send: a typed document as a cultnet.document_put_raw.v0 message in one length-prefixed frame.
put = document_put_raw(
message_id="put-1",
key="note:hello",
schema_id=note_doc.catalog_entry().schema_id,
stored_at="2026-01-01T00:00:00Z",
payload=note_doc.encode_payload(Note("Hello", "sent over CultNet")),
)
sender.sendall(encode_frame(put.to_bytes()))
# Receive: read the frame, parse the message, apply its record to a second cache.
message = parse_message(read_frame(receiver.makefile("rb")))
assert message.schema_version == "cultnet.document_put_raw.v0"
inbox = CultCache.builder().register_document_type(note_doc).build()
apply_raw_document_record(inbox, schema_document_map([note_doc]), message.body["document"])
sender.close()
receiver.close()
return inbox.get_required(note_doc, "note:hello")
# snippet:end
class FirstCallTests(unittest.TestCase):
def test_a_typed_document_crosses_a_pipe_into_a_second_cache(self) -> None:
self.assertEqual(first_call(), Note("Hello", "sent over CultNet"))
def test_docs_show_the_tested_snippet(self) -> None:
source = Path(__file__).read_text(encoding="utf-8")
snippet = source.split("# snippet:begin\n", 1)[1].split("# snippet:end\n", 1)[0]
docs = (Path(__file__).parents[3] / "docs" / "start" / "README.md").read_text(encoding="utf-8")
self.assertIn("```python\n" + snippet + "```", docs)
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,77 @@
//! The Rust CultNet first call from docs/start/README.md. The docs show the region between the
//! `snippet` markers verbatim, and `docs_show_the_tested_snippet` fails when they differ.
use cultcache_rs::{CultCache, DatabaseEntry};
// snippet:begin
use std::net::{TcpListener, TcpStream};
use std::thread;
use cultnet_rs::{
CultNetDocumentBinding, CultNetDocumentPutOptions, CultNetDocumentRegistry, CultNetWireContract,
TcpFramedTransportConnection, create_tcp_framed_transport_profile,
decode_cultnet_message_from_slice, encode_cultnet_message_to_vec,
};
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
#[cultcache(type = "note")]
struct Note {
#[cultcache(key = 0)]
title: String,
#[cultcache(key = 1)]
body: String,
}
fn first_call() -> anyhow::Result<Note> {
let mut documents = CultNetDocumentRegistry::new();
documents.register(CultNetDocumentBinding::for_entry_with_schema_id::<Note>(
"gamecult.first_call_note.v1".to_string(),
"gamecult.first_call_note.v1".to_string(),
))?;
let profile = |runtime_id| create_tcp_framed_transport_profile(runtime_id, Default::default());
// Receiver: a loopback listener that applies the document put it receives to its own cache.
let listener = TcpListener::bind("127.0.0.1:0")?;
let port = listener.local_addr()?.port();
let receiver = thread::spawn({
let (documents, profile) = (documents.clone(), profile("receiver"));
move || -> anyhow::Result<Note> {
let (stream, _) = listener.accept()?;
let frame = TcpFramedTransportConnection::new(stream, profile).receive()?;
let message = decode_cultnet_message_from_slice(&frame.payload, CultNetWireContract::CultNetSchemaV0)?;
let mut inbox = CultCache::new();
inbox.register_entry_type::<Note>()?;
documents.apply_raw_document_put_message::<Note>(&mut inbox, &message)
}
});
// Sender: build a typed put message, encode it for the wire, send one frame.
let put = documents.create_raw_document_put_message(
"put-1",
"note:hello",
&Note { title: "Hello".to_string(), body: "sent over CultNet".to_string() },
CultNetDocumentPutOptions::default(),
)?;
let bytes = encode_cultnet_message_to_vec(&put, CultNetWireContract::CultNetSchemaV0)?;
TcpFramedTransportConnection::new(TcpStream::connect(("127.0.0.1", port))?, profile("sender"))
.send("schema", &bytes)?;
receiver.join().expect("receiver thread")
}
// snippet:end
#[test]
fn a_typed_document_crosses_loopback_into_a_second_cache() {
let note = first_call().unwrap();
assert_eq!(note, Note { title: "Hello".to_string(), body: "sent over CultNet".to_string() });
}
#[test]
fn docs_show_the_tested_snippet() {
let source = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/first_call.rs")).unwrap();
let begin = source.find("// snippet:begin\n").unwrap() + "// snippet:begin\n".len();
let end = source.find("// snippet:end\n").unwrap();
let docs = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/../../docs/start/README.md")).unwrap();
let block = format!("```rust\n{}```", &source[begin..end]);
assert!(docs.contains(&block), "docs/start/README.md no longer shows tests/first_call.rs verbatim");
}

View file

@ -0,0 +1,78 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import test from "node:test";
// The TypeScript CultNet first-call snippet that docs/start/README.md shows: define a typed document, send it
// over a loopback TCP pipe as a cultnet.document_put_raw.v0 message, receive it into a second cache. The docs show
// the region between the `snippet` markers verbatim, except that the package import reads
// "cultnet-ts" where this file reads "../src"; the docs test below fails when they differ.
// snippet:begin
import { createServer, connect } from "node:net";
import { z } from "zod";
import { CultCache, defineDocumentType } from "@gamecult/cultcache-ts";
import {
CultNetDocumentRegistry,
createTcpFramedCultNetPeer,
defineCultNetDocumentBinding,
} from "../src";
const noteDocument = defineDocumentType({
type: "note",
schemaId: "gamecult.first_call_note.v1",
schemaName: "gamecult.first_call_note",
schemaVersion: "gamecult.first_call_note.v1",
schema: z.object({ title: z.string(), body: z.string() }),
name: "title",
});
const documents = new CultNetDocumentRegistry([defineCultNetDocumentBinding({ definition: noteDocument })]);
async function firstCall() {
const options = { runtimeId: "first-call", wireContract: "cultnet.schema.v0" } as const;
const inbox = CultCache.builder().withDocumentType(noteDocument).build(); // the receiver's cache
// Receiver: a loopback listener whose peer applies each document put to the inbox.
const received = new Promise<void>((resolve, reject) => {
const server = createServer((socket) => {
const peer = createTcpFramedCultNetPeer(socket, options);
peer.on("invalidMessage", reject);
peer.on("message", async (message) => {
if (message.schemaVersion !== "cultnet.document_put_raw.v0") return;
await documents.applyRawDocumentPutMessage(inbox, message);
peer.close();
server.close();
resolve();
});
}).listen(0, "127.0.0.1", () => {
// Sender: connect, build a typed put message, send it.
const { port } = server.address() as { port: number };
const sender = createTcpFramedCultNetPeer(connect(port, "127.0.0.1"), options);
sender.send(
documents.createRawDocumentPutMessage(documents.get("note")!, "put-1", "note:hello", {
title: "Hello",
body: "sent over CultNet",
}),
);
received.finally(() => sender.close());
});
});
await received;
return inbox.get(noteDocument, "note:hello");
}
// snippet:end
test("send a typed document over loopback and receive it into a second cache", async () => {
const note = await firstCall();
assert.deepEqual(note, { title: "Hello", body: "sent over CultNet" });
});
test("docs show the tested snippet", async () => {
// Compiled to dist-test/test/first-call.test.js, so the package root is two levels up.
const source = await readFile(join(__dirname, "..", "..", "test", "first-call.test.ts"), "utf8");
const snippet = source
.split("// snippet:begin\n")[1]
.split("// snippet:end\n")[0]
.replace('"../src"', '"cultnet-ts"');
const docs = (await readFile(join(__dirname, "..", "..", "..", "..", "docs", "start", "README.md"), "utf8")).replace(/\r\n/g, "\n");
assert.ok(docs.includes("```ts\n" + snippet + "```"), "docs/start/README.md no longer shows test/first-call.test.ts");
});

View file

@ -0,0 +1,80 @@
#nullable enable
using System;
using System.IO;
using NUnit.Framework;
// snippet:begin
using System.Net;
using System.Net.Sockets;
using System.Threading.Tasks;
using GameCult.Caching;
using GameCult.Networking;
using MessagePack;
[CultDocument("gamecult.first_call_note", "gamecult.first_call_note.v1")]
[MessagePackObject]
public sealed class Note
{
[Key(0)] [CultName] public string Title = string.Empty;
[Key(1)] public string Body = string.Empty;
}
public static class CultNetFirstCall
{
public static async Task<Note?> Run()
{
var inbox = new CultCache(); // the receiver's cache
var documents = new CultNetDocumentRegistry();
var received = new TaskCompletionSource<bool>();
// Receive: a loopback listener that applies each raw document put to the inbox.
using var server = new TcpFramedCultNetSchemaServer(new TcpListener(IPAddress.Loopback, 0));
server.OnCultNet<CultNetDocumentPutRawMessage>(async (put, peer) =>
{
await documents.ApplyRawDocumentPutMessageAsync<Note>(inbox, put);
received.TrySetResult(true);
});
// Send: a typed document as a cultnet.document_put_raw.v0 message.
using var client = new TcpFramedCultNetSchemaClient();
client.Connect("127.0.0.1", server.LocalEndPoint.Port);
var handle = new CultRecordHandle<Note>(new CultRecordKey("note:hello"));
client.SendCultNet(documents.CreateRawDocumentPutMessage(
"put-1", handle, new Note { Title = "Hello", Body = "sent over CultNet" }));
await received.Task.WaitAsync(TimeSpan.FromSeconds(5));
return inbox.Get<Note>(handle.Key);
}
}
// snippet:end
namespace GameCult.Networking.Tests
{
// The C# CultNet first-call snippet that docs/start/README.md shows verbatim: define a typed document, send it
// over a loopback TCP pipe, receive it into a second cache. DocsShowTheTestedSnippet fails when they differ.
public class CultNetFirstCallTests
{
[Test]
public async Task SendATypedDocumentOverLoopbackAndReceiveItIntoASecondCache()
{
var note = await CultNetFirstCall.Run();
Assert.That(note, Is.Not.Null);
Assert.That(note!.Title, Is.EqualTo("Hello"));
Assert.That(note.Body, Is.EqualTo("sent over CultNet"));
}
[Test]
public void DocsShowTheTestedSnippet()
{
var source = File.ReadAllText(SourcePath());
const string begin = "// snippet:begin\n";
var start = source.IndexOf(begin, StringComparison.Ordinal) + begin.Length;
var snippet = source[start..source.IndexOf("// snippet:end\n", StringComparison.Ordinal)];
var docs = File.ReadAllText(Path.Combine(Path.GetDirectoryName(SourcePath())!, "..", "..", "docs", "start", "README.md"));
Assert.That(docs.Replace("\r\n", "\n"), Does.Contain("```csharp\n" + snippet + "```"),
"docs/start/README.md no longer shows tests/GameCult.Networking.Tests/CultNetFirstCallTests.cs verbatim");
}
static string SourcePath([System.Runtime.CompilerServices.CallerFilePath] string path = "") => path;
}
}