Some checks failed
CultMesh QUIC native / Build win32-x64 (pull_request) Has been skipped
CultMesh QUIC native / Build linux-x64 (pull_request) Has been skipped
CultMesh QUIC native / Release native binaries (pull_request) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Failing after 6m11s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 26s
# Conflicts: # docs/start/README.md
466 lines
18 KiB
Markdown
466 lines
18 KiB
Markdown
# Start Here
|
|
|
|
CultLib is one stack in several languages: **CultCache** (typed `.cc` persistence),
|
|
**CultNet** (schema-v0 wire messages and transports) on top of it, and **CultMesh** (Verse
|
|
discovery, replication, authority) on top of that. **CultMath** and **Geometry** sit beside the
|
|
stack as portable numerics and engine-neutral geometry. The C# runtime in `src/` is the
|
|
reference; the other runtimes keep wire parity with it.
|
|
|
|
If you want typed state on disk, start with CultCache. Everything else assumes it.
|
|
|
|
## Language x package
|
|
|
|
A cell links a snippet that a test in this repo runs, or says **not yet**. "Package" links
|
|
the package's own README or source, which is real but carries no tested first-call snippet from
|
|
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 | [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
|
|
[package chooser in the top-level README](../../README.md#which-package-do-i-want).
|
|
|
|
## C# first call
|
|
|
|
Define a typed document, put it in a cache backed by a `.cc` file, read it back through a second
|
|
cache. The source is
|
|
[`tests/GameCult.Caching.Tests/FirstCallTests.cs`](../../tests/GameCult.Caching.Tests/FirstCallTests.cs),
|
|
which is what `dotnet test tests/GameCult.Caching.Tests --filter FirstCallTests` runs, so the snippet
|
|
cannot drift without the test failing.
|
|
|
|
```csharp
|
|
using System.Threading.Tasks;
|
|
using GameCult.Caching;
|
|
using GameCult.Caching.MessagePack;
|
|
using MessagePack;
|
|
|
|
[CultDocument("gamecult.first_call_item", "gamecult.first_call_item.v1")]
|
|
[MessagePackObject]
|
|
public sealed class Item
|
|
{
|
|
[Key(0)] [CultName] public string Name = string.Empty;
|
|
[Key(1)] [CultIndex] public string Category = string.Empty;
|
|
[Key(2)] public int Value;
|
|
}
|
|
|
|
public static class FirstCall
|
|
{
|
|
public static async Task<Item?> Run(string path)
|
|
{
|
|
var store = new SingleFileMessagePackBackingStore(path);
|
|
var cache = new CultCache();
|
|
cache.AddBackingStore(store);
|
|
var handle = await cache.AddAsync(new Item { Name = "Potion", Category = "Consumable", Value = 50 });
|
|
store.PushAll(); // write the .cc file
|
|
|
|
var reopened = new CultCache();
|
|
reopened.AddBackingStore(new SingleFileMessagePackBackingStore(path));
|
|
await reopened.PullAllBackingStoresAsync(); // read it back
|
|
var potion = reopened.Get<Item>(handle.Key); // by key
|
|
var byName = reopened.GetByName<Item>("Potion"); // by [CultName]
|
|
return byName ?? potion;
|
|
}
|
|
}
|
|
```
|
|
|
|
`FirstCall.Run` is the call; a second test in that file fails if the block above stops matching it. More: `src/GameCult.Caching/README.md`.
|
|
|
|
## TypeScript first call
|
|
|
|
Same shape in TypeScript: the document is a name, a schema id and a `parse`-able schema (zod here),
|
|
and the `.cc` file is wire-compatible with the C# one. The source is
|
|
[`packages/cultcache-ts/test/first-call.test.ts`](../../packages/cultcache-ts/test/first-call.test.ts),
|
|
run by `npm test` in `packages/cultcache-ts` (or `node --test dist-test/test/first-call.test.js`
|
|
after `tsc`).
|
|
|
|
```ts
|
|
import { z } from "zod";
|
|
import {
|
|
CultCache,
|
|
SingleFileMessagePackBackingStore,
|
|
defineDocumentRegistry,
|
|
defineDocumentType,
|
|
} from "@gamecult/cultcache-ts";
|
|
|
|
const itemDocument = defineDocumentType({
|
|
type: "item",
|
|
schemaId: "gamecult.first_call_item.v1",
|
|
schemaName: "gamecult.first_call_item",
|
|
schemaVersion: "gamecult.first_call_item.v1",
|
|
schema: z.object({ name: z.string(), category: z.string(), value: z.number().int() }),
|
|
name: "name",
|
|
indexes: { category: "category" },
|
|
});
|
|
const registry = defineDocumentRegistry(itemDocument);
|
|
|
|
async function firstCall(path: string) {
|
|
const cache = CultCache.builder()
|
|
.withRegistry(registry)
|
|
.withGenericStore(new SingleFileMessagePackBackingStore(path))
|
|
.build();
|
|
await cache.pullAllBackingStores();
|
|
await cache.put(itemDocument, "item:potion", { name: "Potion", category: "Consumable", value: 50 });
|
|
|
|
const reopened = CultCache.builder()
|
|
.withRegistry(registry)
|
|
.withGenericStore(new SingleFileMessagePackBackingStore(path))
|
|
.build();
|
|
await reopened.pullAllBackingStores();
|
|
const potion = reopened.get(itemDocument, "item:potion");
|
|
const byName = reopened.getByName(itemDocument, "Potion");
|
|
return { potion, byName };
|
|
}
|
|
```
|
|
|
|
The test imports from `../src/index` where the block reads `@gamecult/cultcache-ts`; a second test in that file fails if the block stops matching the rest. More:
|
|
`packages/cultcache-ts/README.md`.
|
|
|
|
## Rust first call
|
|
|
|
Same shape with `derive(DatabaseEntry)`: slots are `#[cultcache(key = N)]`, so the `.cc` payload is the
|
|
slot-indexed array the C# runtime writes. The source is
|
|
[`packages/cultcache-rs/tests/first_call.rs`](../../packages/cultcache-rs/tests/first_call.rs), run by
|
|
`cargo test --test first_call` in `packages/cultcache-rs`; a second test in that file fails if the block
|
|
below stops matching the file. The Rust runtime has no name lookup, so the read-back is by key. More:
|
|
`packages/cultcache-rs/README.md`.
|
|
|
|
```rust
|
|
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
|
|
#[cultcache(type = "item")]
|
|
struct Item {
|
|
#[cultcache(key = 0)]
|
|
name: String,
|
|
#[cultcache(key = 1)]
|
|
category: String,
|
|
#[cultcache(key = 2, default)]
|
|
value: u32,
|
|
}
|
|
|
|
fn first_call() -> anyhow::Result<()> {
|
|
let dir = tempfile::tempdir()?;
|
|
let path = dir.path().join("first-call.cc");
|
|
|
|
let mut cache = CultCache::new();
|
|
cache.register_entry_type::<Item>()?;
|
|
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&path));
|
|
cache.pull_all_backing_stores()?;
|
|
cache.put("item:potion", &Item {
|
|
name: "Potion".to_string(),
|
|
category: "Consumable".to_string(),
|
|
value: 50,
|
|
})?; // written to the .cc file before this returns
|
|
|
|
let mut reopened = CultCache::new();
|
|
reopened.register_entry_type::<Item>()?;
|
|
reopened.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&path));
|
|
reopened.pull_all_backing_stores()?; // read it back
|
|
let potion = reopened.get_required::<Item>("item:potion")?;
|
|
assert_eq!(potion.name, "Potion");
|
|
assert_eq!(potion.value, 50);
|
|
Ok(())
|
|
}
|
|
```
|
|
|
|
## Python first call
|
|
|
|
A document is a definition with an encoder and decoder; `name=` and `indexes=` register the name lookup
|
|
and indexes. The source is
|
|
[`packages/cultcache-py/tests/test_first_call.py`](../../packages/cultcache-py/tests/test_first_call.py),
|
|
run by `python -m unittest tests.test_first_call` in `packages/cultcache-py` with `src` on `PYTHONPATH`
|
|
and `msgpack` installed (the store needs it). A second test fails if the block below stops matching the
|
|
file. More: `packages/cultcache-py/README.md`.
|
|
|
|
```python
|
|
import tempfile
|
|
from dataclasses import asdict, dataclass
|
|
from pathlib import Path
|
|
|
|
from cultcache_py import CultCache, SingleFileMessagePackBackingStore, define_document_type
|
|
|
|
|
|
@dataclass
|
|
class Item:
|
|
name: str
|
|
category: str
|
|
value: int
|
|
|
|
|
|
item_doc = define_document_type(
|
|
"item",
|
|
encode=lambda item: asdict(item),
|
|
decode=lambda raw: Item(**raw),
|
|
name="name",
|
|
indexes={"category": "category"},
|
|
)
|
|
|
|
|
|
def first_call() -> Item:
|
|
path = Path(tempfile.mkdtemp()) / "first-call.cc"
|
|
|
|
cache = (
|
|
CultCache.builder()
|
|
.register_document_type(item_doc)
|
|
.add_generic_store(SingleFileMessagePackBackingStore(path))
|
|
.build()
|
|
)
|
|
cache.pull_all_backing_stores()
|
|
cache.put(item_doc, "item:potion", Item("Potion", "Consumable", 50)) # written to the .cc file
|
|
|
|
reopened = (
|
|
CultCache.builder()
|
|
.register_document_type(item_doc)
|
|
.add_generic_store(SingleFileMessagePackBackingStore(path))
|
|
.build()
|
|
)
|
|
reopened.pull_all_backing_stores() # read it back
|
|
assert reopened.get_by_name(item_doc, "Potion") == reopened.get_required(item_doc, "item:potion")
|
|
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`.
|
|
- Application path, four chapters: `src/GameCult.Mesh/docs/getting-started/README.md`.
|