1004 lines
36 KiB
TypeScript
1004 lines
36 KiB
TypeScript
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { Duplex } from "node:stream";
|
|
import dgram, { type Socket } from "node:dgram";
|
|
import { rmSync, mkdtempSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import { tmpdir } from "node:os";
|
|
|
|
import { z } from "zod";
|
|
import {
|
|
CultCache,
|
|
SingleFileMessagePackBackingStore,
|
|
defineDocumentType,
|
|
} from "cultcache-ts";
|
|
|
|
import {
|
|
CultNetClientSecurityOptions,
|
|
CultNetDocumentRegistry,
|
|
CultNetPeer,
|
|
CultNetRudpSession,
|
|
CultNetRudpSocketTransportConnection,
|
|
CultNetSchemaRegistry,
|
|
CultNetSecret,
|
|
CultNetServerSecurityOptions,
|
|
TcpFramedTransportConnection,
|
|
cultNetSchemas,
|
|
cultNetBuiltinSchemaRegistry,
|
|
createTcpFramedTransportProfile,
|
|
createRudpTransportProfile,
|
|
defineCultNetDocumentBinding,
|
|
decodeRudpPacket,
|
|
encodeCultNetMessageForWire,
|
|
encodeRudpPacket,
|
|
ghostlightAgentStateGeneratedContract,
|
|
parseCultNetMessage,
|
|
validateGhostlightAgentStateGenerated,
|
|
validateGhostlightAgentState,
|
|
type CultNetLoginMessage,
|
|
type GhostlightAgentStateShape,
|
|
type GhostlightAgentStateDocument,
|
|
} from "../src";
|
|
import {
|
|
INTEROP_SCHEMA_VERSION,
|
|
createInteropFormatter,
|
|
createLegacyInteropNoteFormatter,
|
|
createMismatchedInteropNoteFormatter,
|
|
type InteropNote,
|
|
} from "./interop/cultnet-interop-shared";
|
|
|
|
class LinkedDuplex extends Duplex {
|
|
peer?: LinkedDuplex;
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
|
_read(): void {}
|
|
|
|
_write(
|
|
chunk: Buffer,
|
|
_encoding: BufferEncoding,
|
|
callback: (error?: Error | null) => void,
|
|
): void {
|
|
this.peer?.push(Buffer.from(chunk));
|
|
callback();
|
|
}
|
|
|
|
_final(callback: (error?: Error | null) => void): void {
|
|
this.peer?.push(null);
|
|
callback();
|
|
}
|
|
}
|
|
|
|
function createDuplexPair(): { a: Duplex; b: Duplex } {
|
|
const a = new LinkedDuplex();
|
|
const b = new LinkedDuplex();
|
|
a.peer = b;
|
|
b.peer = a;
|
|
return { a, b };
|
|
}
|
|
|
|
async function bindUdpSocket(): Promise<Socket> {
|
|
const socket = dgram.createSocket("udp4");
|
|
await new Promise<void>((resolve) => socket.bind(0, "127.0.0.1", resolve));
|
|
return socket;
|
|
}
|
|
|
|
function udpPort(socket: Socket): number {
|
|
const address = socket.address();
|
|
assert.notEqual(typeof address, "string");
|
|
return address.port;
|
|
}
|
|
|
|
async function waitFor(predicate: () => boolean, description: string): Promise<void> {
|
|
const startedAt = Date.now();
|
|
while (!predicate()) {
|
|
if (Date.now() - startedAt > 1_000) {
|
|
throw new Error(`Timed out waiting for ${description}.`);
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
}
|
|
}
|
|
|
|
test("CultNet secret helpers round-trip encrypted strings and validate sessions", () => {
|
|
const serverSecurity = CultNetServerSecurityOptions.development();
|
|
const clientSecurity = serverSecurity.toClientOptions();
|
|
const nonce = CultNetSecret.newNonce();
|
|
const encrypted = CultNetSecret.encryptString("hello", nonce, clientSecurity);
|
|
assert.ok(encrypted);
|
|
assert.equal(CultNetSecret.decryptString(encrypted, nonce, serverSecurity), "hello");
|
|
|
|
const token = CultNetSecret.createSessionToken(
|
|
"runtime-face",
|
|
new Date(Date.now() + 60_000),
|
|
serverSecurity,
|
|
);
|
|
const validated = CultNetSecret.tryValidateSessionToken(token, serverSecurity);
|
|
assert.ok(validated);
|
|
assert.equal(validated?.userId, "runtime-face");
|
|
assert.equal(validated?.sessionVersion, 0);
|
|
});
|
|
|
|
test("CultNet secret helpers validate C# and Python compatible versioned sessions", () => {
|
|
const serverSecurity = CultNetServerSecurityOptions.development();
|
|
const expires = new Date(Date.now() + 60_000);
|
|
const token = CultNetSecret.createSessionToken(
|
|
"318fb4b6-ff5e-4c4f-b911-d81807de53a8",
|
|
expires,
|
|
serverSecurity,
|
|
42,
|
|
);
|
|
const [payload] = token.split(".");
|
|
assert.equal(
|
|
new TextDecoder().decode(CultNetSecret.fromBase64Url(payload!)),
|
|
`318fb4b6ff5e4c4fb911d81807de53a8|${Math.floor(expires.getTime() / 1000)}|42`,
|
|
);
|
|
|
|
const validated = CultNetSecret.tryValidateSessionToken(token, serverSecurity);
|
|
assert.ok(validated);
|
|
assert.equal(validated?.userId, "318fb4b6ff5e4c4fb911d81807de53a8");
|
|
assert.equal(validated?.sessionVersion, 42);
|
|
assert.throws(
|
|
() => CultNetSecret.createSessionToken("runtime-face", expires, serverSecurity, 1),
|
|
/Guid-compatible/,
|
|
);
|
|
});
|
|
|
|
test("CultNet secret helpers validate Python-created versioned sessions", () => {
|
|
const token = [
|
|
"MzE4ZmI0YjZmZjVlNGM0ZmI5MTFkODE4MDdkZTUzYTh8MjA1MTIyMjQwMHw3Nw",
|
|
"jRrUiE5Om7NQVKMJP4PkBkLFVLXqNb8Uu9jg4VG13pU",
|
|
].join(".");
|
|
|
|
const validated = CultNetSecret.tryValidateSessionToken(token, CultNetServerSecurityOptions.development());
|
|
assert.ok(validated);
|
|
assert.equal(validated?.userId, "318fb4b6ff5e4c4fb911d81807de53a8");
|
|
assert.equal(validated?.sessionVersion, 77);
|
|
});
|
|
|
|
test("CultNet peer frames and decodes typed messages over a direct pipe", async () => {
|
|
const { a, b } = createDuplexPair();
|
|
const sender = new CultNetPeer(a, { wireContract: "cultnet.schema.v0" });
|
|
const receiver = new CultNetPeer(b, { wireContract: "cultnet.schema.v0" });
|
|
|
|
const message = await new Promise<ReturnType<typeof parseCultNetMessage>>((resolve, reject) => {
|
|
receiver.once("message", resolve);
|
|
receiver.once("invalidMessage", reject);
|
|
sender.sendHello({
|
|
schemaVersion: "cultnet.hello.v0",
|
|
runtimeId: "voidbot-main",
|
|
runtimeKind: "node-worker",
|
|
agentId: "void",
|
|
displayName: "Void",
|
|
supportedDocumentTypes: ["ghostlight.agent-state"],
|
|
transportProfiles: [
|
|
{
|
|
schemaVersion: "cultnet.transport_profile.v0",
|
|
runtimeId: "voidbot-main",
|
|
transports: [
|
|
{
|
|
transportId: "direct-pipe",
|
|
protocol: "tcp_framed",
|
|
wireContracts: ["cultnet.schema.v0"],
|
|
channels: [{ channelId: "schema", delivery: "reliable", ordering: "ordered" }],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
assert.equal(message.schemaVersion, "cultnet.hello.v0");
|
|
if (message.schemaVersion === "cultnet.hello.v0") {
|
|
assert.equal(message.runtimeId, "voidbot-main");
|
|
assert.equal(message.agentId, "void");
|
|
assert.equal(message.transportProfiles?.[0]?.transports[0]?.protocol, "tcp_framed");
|
|
}
|
|
|
|
sender.close();
|
|
receiver.close();
|
|
});
|
|
|
|
test("tcp_framed transport carries raw schema channel payloads with stats", async () => {
|
|
const { a, b } = createDuplexPair();
|
|
const left = new TcpFramedTransportConnection(a, createTcpFramedTransportProfile("left"));
|
|
const right = new TcpFramedTransportConnection(b, createTcpFramedTransportProfile("right"));
|
|
|
|
const frame = await new Promise<{ channelId: string; payload: Uint8Array }>((resolve, reject) => {
|
|
right.once("frame", resolve);
|
|
right.once("error", reject);
|
|
left.send("schema", Buffer.from("payload", "utf8"));
|
|
});
|
|
|
|
assert.equal(frame.channelId, "schema");
|
|
assert.equal(Buffer.from(frame.payload).toString("utf8"), "payload");
|
|
assert.equal(left.stats.framesSent, 1);
|
|
assert.equal(right.stats.framesReceived, 1);
|
|
assert.throws(() => left.send("unreliable", Buffer.alloc(0)), /only supports the schema channel/);
|
|
|
|
left.close();
|
|
right.close();
|
|
});
|
|
|
|
test("rudp packet codec has a deterministic reliable ordered fixture", () => {
|
|
const encoded = encodeRudpPacket({
|
|
packetType: "data",
|
|
connectionId: 0x01020304,
|
|
sequence: 0x0000002a,
|
|
ack: 0x00000029,
|
|
ackMask: 0x80000001,
|
|
channelId: "schema",
|
|
reliable: true,
|
|
ordered: true,
|
|
fragmentId: 7,
|
|
fragmentIndex: 1,
|
|
fragmentCount: 3,
|
|
payload: Buffer.from("hello", "utf8"),
|
|
});
|
|
|
|
assert.equal(
|
|
Buffer.from(encoded).toString("hex"),
|
|
"434e523000030b2a010203040000002a0000002980000001000700010003000000050600736368656d6168656c6c6f",
|
|
);
|
|
|
|
const decoded = decodeRudpPacket(encoded);
|
|
assert.equal(decoded.packetType, "data");
|
|
assert.equal(decoded.connectionId, 0x01020304);
|
|
assert.equal(decoded.sequence, 0x0000002a);
|
|
assert.equal(decoded.ack, 0x00000029);
|
|
assert.equal(decoded.ackMask, 0x80000001);
|
|
assert.equal(decoded.channelId, "schema");
|
|
assert.equal(decoded.reliable, true);
|
|
assert.equal(decoded.ordered, true);
|
|
assert.equal(decoded.sequenced, false);
|
|
assert.equal(decoded.fragmentId, 7);
|
|
assert.equal(decoded.fragmentIndex, 1);
|
|
assert.equal(decoded.fragmentCount, 3);
|
|
assert.equal(Buffer.from(decoded.payload ?? []).toString("utf8"), "hello");
|
|
});
|
|
|
|
test("rudp transport profile advertises state and realtime channel semantics", () => {
|
|
const profile = createRudpTransportProfile("node-rudp", {
|
|
transportId: "public-rudp",
|
|
host: "127.0.0.1",
|
|
port: 7777,
|
|
maxPayloadBytes: 1200,
|
|
maxFragmentBytes: 1000,
|
|
});
|
|
|
|
assert.equal(profile.transports[0]?.protocol, "rudp");
|
|
assert.deepEqual(
|
|
profile.transports[0]?.channels.map((channel) => [channel.channelId, channel.delivery, channel.ordering]),
|
|
[
|
|
["schema", "reliable", "ordered"],
|
|
["latest", "unreliable", "sequenced"],
|
|
["realtime", "unreliable", "unordered"],
|
|
],
|
|
);
|
|
});
|
|
|
|
test("rudp session handshake acks reliable connect and accept packets", () => {
|
|
const client = new CultNetRudpSession({ connectionId: 0x0a0b0c0d, initialSequence: 1, resendDelayMs: 50 });
|
|
const server = new CultNetRudpSession({ connectionId: 0x0a0b0c0d, initialSequence: 100, resendDelayMs: 50 });
|
|
|
|
const connect = client.createConnect(0, Buffer.from("join", "utf8"));
|
|
assert.equal(connect.packetType, "connect");
|
|
assert.equal(connect.sequence, 1);
|
|
assert.deepEqual(client.pendingReliableSequences, [1]);
|
|
|
|
const accept = server.acceptConnect(connect, 10, Buffer.from("ok", "utf8"));
|
|
assert.equal(accept.packetType, "accept");
|
|
assert.equal(accept.ack, 1);
|
|
assert.equal(server.connected, true);
|
|
assert.deepEqual(server.pendingReliableSequences, [100]);
|
|
|
|
client.receive(accept, 20);
|
|
assert.equal(client.connected, true);
|
|
assert.deepEqual(client.pendingReliableSequences, []);
|
|
|
|
const ack = client.createAck();
|
|
assert.equal(ack.ack, 100);
|
|
server.receive(ack, 30);
|
|
assert.deepEqual(server.pendingReliableSequences, []);
|
|
});
|
|
|
|
test("rudp session computes ack masks and clears pending reliable packets", () => {
|
|
const sender = new CultNetRudpSession({ connectionId: 7, initialSequence: 10, resendDelayMs: 100 });
|
|
const receiver = new CultNetRudpSession({ connectionId: 7, initialSequence: 200, resendDelayMs: 100 });
|
|
sender.receive({ packetType: "accept", connectionId: 7, sequence: 1, ack: 0, ackMask: 0, channelId: "control" });
|
|
receiver.receive({ packetType: "accept", connectionId: 7, sequence: 2, ack: 0, ackMask: 0, channelId: "control" });
|
|
|
|
const first = sender.send("schema", Buffer.from("first"), { reliable: true, ordered: true, nowMs: 0 });
|
|
const second = sender.send("schema", Buffer.from("second"), { reliable: true, ordered: true, nowMs: 0 });
|
|
const third = sender.send("schema", Buffer.from("third"), { reliable: true, ordered: true, nowMs: 0 });
|
|
assert.deepEqual(sender.pendingReliableSequences, [10, 11, 12]);
|
|
|
|
receiver.receive(first);
|
|
receiver.receive(third);
|
|
const ackWithGap = receiver.createAck();
|
|
assert.equal(ackWithGap.ack, 12);
|
|
assert.equal(ackWithGap.ackMask, 0b10 | (1 << 9));
|
|
sender.receive(ackWithGap);
|
|
assert.deepEqual(sender.pendingReliableSequences, [11]);
|
|
|
|
receiver.receive(second);
|
|
const fullAck = receiver.createAck();
|
|
assert.equal(fullAck.ack, 12);
|
|
assert.equal(fullAck.ackMask, 0b11 | (1 << 9));
|
|
sender.receive(fullAck);
|
|
assert.deepEqual(sender.pendingReliableSequences, []);
|
|
});
|
|
|
|
test("rudp session schedules reliable resends until acked", () => {
|
|
const session = new CultNetRudpSession({ connectionId: 99, initialSequence: 1, resendDelayMs: 100 });
|
|
session.receive({ packetType: "accept", connectionId: 99, sequence: 50, ack: 0, ackMask: 0, channelId: "control" });
|
|
const sent = session.send("schema", Buffer.from("payload"), { reliable: true, ordered: true, nowMs: 10 });
|
|
|
|
assert.deepEqual(session.dueResends(90), []);
|
|
assert.deepEqual(session.dueResends(110).map((packet) => packet.sequence), [sent.sequence]);
|
|
assert.deepEqual(session.dueResends(150), []);
|
|
|
|
session.receive({ packetType: "ack", connectionId: 99, sequence: 51, ack: sent.sequence, ackMask: 0, channelId: "control" });
|
|
assert.deepEqual(session.dueResends(250), []);
|
|
});
|
|
|
|
test("rudp session pings and detects receive timeout", () => {
|
|
const client = new CultNetRudpSession({ connectionId: 101, initialSequence: 1 });
|
|
const server = new CultNetRudpSession({ connectionId: 101, initialSequence: 100 });
|
|
const connect = client.createConnect(0, Buffer.from("join"));
|
|
const accept = server.acceptConnect(connect, 10);
|
|
client.receive(accept, 20);
|
|
|
|
const ping = client.createPing(Buffer.from("pulse"));
|
|
const pingResult = server.receive(ping, 30);
|
|
assert.equal(pingResult.reply?.packetType, "pong");
|
|
assert.deepEqual(Buffer.from(pingResult.reply?.payload ?? []), Buffer.from("pulse"));
|
|
|
|
const pongResult = client.receive(pingResult.reply!, 40);
|
|
assert.equal(pongResult.pong, true);
|
|
assert.deepEqual(Buffer.from(pongResult.pongPayload ?? []), Buffer.from("pulse"));
|
|
assert.equal(client.checkTimeout(90, 50), false);
|
|
assert.equal(client.checkTimeout(91, 50), true);
|
|
assert.equal(client.connected, false);
|
|
});
|
|
|
|
test("rudp session bounds pending reliable packets before enqueue", () => {
|
|
const session = new CultNetRudpSession({ connectionId: 102, initialSequence: 1, maxPendingReliablePackets: 2 });
|
|
session.receive({ packetType: "accept", connectionId: 102, sequence: 50, ack: 0, ackMask: 0, channelId: "control" });
|
|
|
|
session.send("schema", Buffer.from("first"), { reliable: true, ordered: true });
|
|
session.send("schema", Buffer.from("second"), { reliable: true, ordered: true });
|
|
assert.throws(
|
|
() => session.send("schema", Buffer.from("third"), { reliable: true, ordered: true }),
|
|
/reliable send queue is full/,
|
|
);
|
|
assert.deepEqual(session.pendingReliableSequences, [1, 2]);
|
|
|
|
const fragmented = new CultNetRudpSession({ connectionId: 103, initialSequence: 1, maxPendingReliablePackets: 3 });
|
|
fragmented.receive({ packetType: "accept", connectionId: 103, sequence: 50, ack: 0, ackMask: 0, channelId: "control" });
|
|
assert.throws(
|
|
() => fragmented.sendMany("schema", Buffer.from("fragment-me"), { reliable: true, ordered: true, maxFragmentBytes: 3 }),
|
|
/reliable send queue is full/,
|
|
);
|
|
assert.deepEqual(fragmented.pendingReliableSequences, []);
|
|
});
|
|
|
|
test("rudp session suppresses duplicates and delivers reliable ordered payloads in sequence", () => {
|
|
const sender = new CultNetRudpSession({ connectionId: 123, initialSequence: 1 });
|
|
const receiver = new CultNetRudpSession({ connectionId: 123, initialSequence: 100 });
|
|
sender.receive({ packetType: "accept", connectionId: 123, sequence: 90, ack: 0, ackMask: 0, channelId: "control" });
|
|
receiver.receive({ packetType: "accept", connectionId: 123, sequence: 91, ack: 0, ackMask: 0, channelId: "control" });
|
|
|
|
const first = sender.send("schema", Buffer.from("first"), { reliable: true, ordered: true });
|
|
const second = sender.send("schema", Buffer.from("second"), { reliable: true, ordered: true });
|
|
const third = sender.send("schema", Buffer.from("third"), { reliable: true, ordered: true });
|
|
|
|
assert.deepEqual(receiver.receive(first).delivered.map((frame) => Buffer.from(frame.payload).toString("utf8")), ["first"]);
|
|
assert.deepEqual(receiver.receive(third).delivered, []);
|
|
assert.deepEqual(receiver.receive(first).delivered, []);
|
|
assert.deepEqual(receiver.receive(second).delivered.map((frame) => Buffer.from(frame.payload).toString("utf8")), [
|
|
"second",
|
|
"third",
|
|
]);
|
|
});
|
|
|
|
test("rudp session fragments and reassembles reliable ordered payloads", () => {
|
|
const sender = new CultNetRudpSession({ connectionId: 456, initialSequence: 1 });
|
|
const receiver = new CultNetRudpSession({ connectionId: 456, initialSequence: 100 });
|
|
sender.receive({ packetType: "accept", connectionId: 456, sequence: 90, ack: 0, ackMask: 0, channelId: "control" });
|
|
receiver.receive({ packetType: "accept", connectionId: 456, sequence: 91, ack: 0, ackMask: 0, channelId: "control" });
|
|
|
|
const packets = sender.sendMany("schema", Buffer.from("fragment-me-please"), {
|
|
reliable: true,
|
|
ordered: true,
|
|
nowMs: 10,
|
|
maxFragmentBytes: 5,
|
|
});
|
|
assert.equal(packets.length, 4);
|
|
assert.deepEqual(packets.map((packet) => packet.fragmentCount), [4, 4, 4, 4]);
|
|
assert.deepEqual(packets.map((packet) => packet.fragmentIndex), [0, 1, 2, 3]);
|
|
assert.ok(packets.every((packet) => packet.fragmentId === packets[0]?.fragmentId));
|
|
|
|
assert.deepEqual(receiver.receive(packets[0]!).delivered, []);
|
|
assert.deepEqual(receiver.receive(packets[1]!).delivered, []);
|
|
assert.deepEqual(receiver.receive(packets[2]!).delivered, []);
|
|
const delivered = receiver.receive(packets[3]!).delivered;
|
|
assert.equal(delivered.length, 1);
|
|
assert.equal(Buffer.from(delivered[0]!.payload).toString("utf8"), "fragment-me-please");
|
|
assert.equal(delivered[0]!.sequence, packets[0]!.sequence);
|
|
});
|
|
|
|
test("rudp socket transport handshakes and carries reliable ordered schema frames over UDP", async () => {
|
|
const serverSocket = await bindUdpSocket();
|
|
const clientSocket = await bindUdpSocket();
|
|
const connectionId = 0x10203040;
|
|
const server = new CultNetRudpSocketTransportConnection({
|
|
runtimeId: "rudp-server",
|
|
socket: serverSocket,
|
|
mode: "server",
|
|
connectionId,
|
|
initialSequence: 100,
|
|
resendDelayMs: 25,
|
|
resendPollMs: 5,
|
|
});
|
|
const client = new CultNetRudpSocketTransportConnection({
|
|
runtimeId: "rudp-client",
|
|
socket: clientSocket,
|
|
mode: "client",
|
|
remoteHost: "127.0.0.1",
|
|
remotePort: udpPort(serverSocket),
|
|
connectionId,
|
|
initialSequence: 1,
|
|
resendDelayMs: 25,
|
|
resendPollMs: 5,
|
|
});
|
|
|
|
try {
|
|
const serverFrame = new Promise<{ channelId: string; payload: Uint8Array }>((resolve, reject) => {
|
|
server.once("frame", resolve);
|
|
server.once("error", reject);
|
|
});
|
|
client.connect(Buffer.from("join", "utf8"));
|
|
await waitFor(() => client.connected && server.connected, "RUDP socket handshake");
|
|
client.send("schema", Buffer.from("client-state", "utf8"));
|
|
|
|
const receivedByServer = await serverFrame;
|
|
assert.equal(receivedByServer.channelId, "schema");
|
|
assert.equal(Buffer.from(receivedByServer.payload).toString("utf8"), "client-state");
|
|
|
|
const clientFrame = new Promise<{ channelId: string; payload: Uint8Array }>((resolve, reject) => {
|
|
client.once("frame", resolve);
|
|
client.once("error", reject);
|
|
});
|
|
server.send("schema", Buffer.from("server-state", "utf8"));
|
|
const receivedByClient = await clientFrame;
|
|
assert.equal(receivedByClient.channelId, "schema");
|
|
assert.equal(Buffer.from(receivedByClient.payload).toString("utf8"), "server-state");
|
|
assert.equal(client.stats.framesSent, 1);
|
|
assert.equal(server.stats.framesReceived, 1);
|
|
assert.equal(server.profile.transports[0]?.protocol, "rudp");
|
|
} finally {
|
|
client.close();
|
|
server.close();
|
|
}
|
|
});
|
|
|
|
test("rudp socket transport carries fragmented reliable ordered schema frames over UDP", async () => {
|
|
const serverSocket = await bindUdpSocket();
|
|
const clientSocket = await bindUdpSocket();
|
|
const connectionId = 0x10203041;
|
|
const server = new CultNetRudpSocketTransportConnection({
|
|
runtimeId: "rudp-fragment-server",
|
|
socket: serverSocket,
|
|
mode: "server",
|
|
connectionId,
|
|
initialSequence: 100,
|
|
resendDelayMs: 25,
|
|
resendPollMs: 5,
|
|
maxFragmentBytes: 8,
|
|
});
|
|
const client = new CultNetRudpSocketTransportConnection({
|
|
runtimeId: "rudp-fragment-client",
|
|
socket: clientSocket,
|
|
mode: "client",
|
|
remoteHost: "127.0.0.1",
|
|
remotePort: udpPort(serverSocket),
|
|
connectionId,
|
|
initialSequence: 1,
|
|
resendDelayMs: 25,
|
|
resendPollMs: 5,
|
|
maxFragmentBytes: 8,
|
|
});
|
|
|
|
try {
|
|
const payload = Buffer.from("this-schema-frame-is-larger-than-one-rudp-fragment", "utf8");
|
|
const serverFrame = new Promise<{ channelId: string; payload: Uint8Array }>((resolve, reject) => {
|
|
server.once("frame", resolve);
|
|
server.once("error", reject);
|
|
});
|
|
client.connect(Buffer.from("join", "utf8"));
|
|
await waitFor(() => client.connected && server.connected, "fragmented RUDP socket handshake");
|
|
client.send("schema", payload);
|
|
|
|
const receivedByServer = await serverFrame;
|
|
assert.equal(receivedByServer.channelId, "schema");
|
|
assert.equal(Buffer.from(receivedByServer.payload).toString("utf8"), payload.toString("utf8"));
|
|
assert.equal(client.stats.framesSent, 1);
|
|
assert.equal(server.stats.framesReceived, 1);
|
|
} finally {
|
|
client.close();
|
|
server.close();
|
|
}
|
|
});
|
|
|
|
test("CultNet peer can speak schema messages through the RUDP socket transport", async () => {
|
|
const serverSocket = await bindUdpSocket();
|
|
const clientSocket = await bindUdpSocket();
|
|
const connectionId = 0x50607080;
|
|
const serverTransport = new CultNetRudpSocketTransportConnection({
|
|
runtimeId: "rudp-peer-server",
|
|
socket: serverSocket,
|
|
mode: "server",
|
|
connectionId,
|
|
initialSequence: 500,
|
|
resendDelayMs: 25,
|
|
resendPollMs: 5,
|
|
});
|
|
const clientTransport = new CultNetRudpSocketTransportConnection({
|
|
runtimeId: "rudp-peer-client",
|
|
socket: clientSocket,
|
|
mode: "client",
|
|
remoteHost: "127.0.0.1",
|
|
remotePort: udpPort(serverSocket),
|
|
connectionId,
|
|
initialSequence: 10,
|
|
resendDelayMs: 25,
|
|
resendPollMs: 5,
|
|
});
|
|
|
|
try {
|
|
const sender = new CultNetPeer(clientTransport, { wireContract: "cultnet.schema.v0" });
|
|
const receiver = new CultNetPeer(serverTransport, { wireContract: "cultnet.schema.v0" });
|
|
clientTransport.connect();
|
|
await waitFor(() => clientTransport.connected && serverTransport.connected, "RUDP peer socket handshake");
|
|
|
|
const message = await new Promise<ReturnType<typeof parseCultNetMessage>>((resolve, reject) => {
|
|
receiver.once("message", resolve);
|
|
receiver.once("invalidMessage", reject);
|
|
clientTransport.once("error", reject);
|
|
serverTransport.once("error", reject);
|
|
sender.sendHello({
|
|
schemaVersion: "cultnet.hello.v0",
|
|
runtimeId: "rudp-peer-client",
|
|
runtimeKind: "node-worker",
|
|
transportProfiles: [clientTransport.profile],
|
|
});
|
|
});
|
|
|
|
assert.equal(message.schemaVersion, "cultnet.hello.v0");
|
|
if (message.schemaVersion === "cultnet.hello.v0") {
|
|
assert.equal(message.runtimeId, "rudp-peer-client");
|
|
assert.equal(message.transportProfiles?.[0]?.transports[0]?.protocol, "rudp");
|
|
}
|
|
|
|
sender.close();
|
|
receiver.close();
|
|
} finally {
|
|
clientTransport.close();
|
|
serverTransport.close();
|
|
}
|
|
});
|
|
|
|
test("CultNet peer can speak through a transport connection", async () => {
|
|
const { a, b } = createDuplexPair();
|
|
const leftTransport = new TcpFramedTransportConnection(a, createTcpFramedTransportProfile("left"));
|
|
const rightTransport = new TcpFramedTransportConnection(b, createTcpFramedTransportProfile("right"));
|
|
const sender = new CultNetPeer(leftTransport, { wireContract: "cultnet.schema.v0" });
|
|
const receiver = new CultNetPeer(rightTransport, { wireContract: "cultnet.schema.v0" });
|
|
|
|
const message = await new Promise<ReturnType<typeof parseCultNetMessage>>((resolve, reject) => {
|
|
receiver.once("message", resolve);
|
|
receiver.once("invalidMessage", reject);
|
|
sender.sendHello({
|
|
schemaVersion: "cultnet.hello.v0",
|
|
runtimeId: "transport-sender",
|
|
runtimeKind: "node-worker",
|
|
});
|
|
});
|
|
|
|
assert.equal(message.schemaVersion, "cultnet.hello.v0");
|
|
if (message.schemaVersion === "cultnet.hello.v0") {
|
|
assert.equal(message.runtimeId, "transport-sender");
|
|
}
|
|
assert.equal(leftTransport.stats.framesSent, 1);
|
|
assert.equal(rightTransport.stats.framesReceived, 1);
|
|
|
|
sender.close();
|
|
receiver.close();
|
|
});
|
|
|
|
test("CultNet can round-trip gamecult.networking.v0 auth messages through the explicit legacy contract", () => {
|
|
const message: CultNetLoginMessage = {
|
|
schemaVersion: "cultnet.login.v0",
|
|
nonce: "bm9uY2U",
|
|
auth: "YXV0aA",
|
|
password: "cGFzc3dvcmQ",
|
|
};
|
|
|
|
const wireValue = encodeCultNetMessageForWire(message, "gamecult.networking.v0");
|
|
assert.deepEqual(wireValue, [
|
|
0,
|
|
[
|
|
Buffer.from("nonce", "utf8"),
|
|
Buffer.from("auth", "utf8"),
|
|
Buffer.from("password", "utf8"),
|
|
],
|
|
]);
|
|
|
|
const decoded = parseCultNetMessage(wireValue, "gamecult.networking.v0");
|
|
assert.deepEqual(decoded, message);
|
|
});
|
|
|
|
test("CultNet schema discovery catalog can advertise canonical schemas without inline bodies by default", () => {
|
|
const response = cultNetBuiltinSchemaRegistry.createCatalogResponse({
|
|
schemaVersion: "cultnet.schema_catalog_request.v0",
|
|
messageId: "catalog-1",
|
|
});
|
|
|
|
const ghostlight = response.schemas.find((schema) => schema.documentType === "ghostlight.agent-state");
|
|
assert.ok(ghostlight);
|
|
assert.equal(ghostlight?.kind, "document_payload");
|
|
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", () => {
|
|
const registry = new CultNetSchemaRegistry([
|
|
{
|
|
schemaId: "https://example.test/contracts/example.schema.json",
|
|
kind: "shared_contract",
|
|
schema: {
|
|
$schema: "https://json-schema.org/draft/2020-12/schema",
|
|
$id: "https://example.test/contracts/example.schema.json",
|
|
type: "object",
|
|
properties: {
|
|
value: { type: "string" },
|
|
},
|
|
required: ["value"],
|
|
additionalProperties: false,
|
|
},
|
|
title: "Example Schema",
|
|
wireContracts: ["cultnet.schema.v0", "gamecult.networking.v0"],
|
|
},
|
|
]);
|
|
|
|
const response = registry.createCatalogResponse({
|
|
schemaVersion: "cultnet.schema_catalog_request.v0",
|
|
messageId: "catalog-legacy",
|
|
includeSchemaJson: true,
|
|
});
|
|
|
|
const wireValue = encodeCultNetMessageForWire(response, "gamecult.networking.v0");
|
|
const decoded = parseCultNetMessage(wireValue, "gamecult.networking.v0");
|
|
assert.equal(decoded.schemaVersion, "cultnet.schema_catalog_response.v0");
|
|
if (decoded.schemaVersion === "cultnet.schema_catalog_response.v0") {
|
|
assert.equal(decoded.messageId, "catalog-legacy");
|
|
assert.equal(decoded.schemas[0]?.schemaId, "https://example.test/contracts/example.schema.json");
|
|
assert.match(decoded.schemas[0]?.schemaJson ?? "", /"value"/u);
|
|
}
|
|
});
|
|
|
|
test("CultNet document registry builds snapshots and applies document puts through CultCache", async () => {
|
|
const tempDir = mkdtempSync(join(tmpdir(), "cultnetts-"));
|
|
|
|
try {
|
|
const documentDefinition = defineDocumentType({
|
|
type: "ghostlight.agent-state",
|
|
schemaId: cultNetSchemas.ghostlightAgentStateSchema.$id,
|
|
schemaName: "ghostlight.agent-state",
|
|
schemaVersion: "ghostlight.agent_state.v0",
|
|
schema: z.custom<GhostlightAgentStateDocument>((value) => {
|
|
try {
|
|
validateGhostlightAgentState(value);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}),
|
|
});
|
|
|
|
const registry = new CultNetDocumentRegistry([
|
|
defineCultNetDocumentBinding({
|
|
definition: documentDefinition,
|
|
payloadSchemaVersion: "ghostlight.agent_state.v0",
|
|
}),
|
|
]);
|
|
|
|
const originStore = new SingleFileMessagePackBackingStore(join(tempDir, "origin.msgpack"));
|
|
const targetStore = new SingleFileMessagePackBackingStore(join(tempDir, "target.msgpack"));
|
|
const originCache = CultCache.builder()
|
|
.withDocumentType(documentDefinition)
|
|
.withGenericStore(originStore)
|
|
.build();
|
|
const targetCache = CultCache.builder()
|
|
.withDocumentType(documentDefinition)
|
|
.withGenericStore(targetStore)
|
|
.build();
|
|
|
|
const payload = validateGhostlightAgentState({
|
|
schema_version: "ghostlight.agent_state.v0",
|
|
world: {
|
|
world_id: "epiphany-face",
|
|
setting: "test harness",
|
|
time: { label: "now" },
|
|
canon_context: ["test"],
|
|
},
|
|
agents: [
|
|
{
|
|
agent_id: "epiphany.persona",
|
|
identity: {
|
|
name: "Persona",
|
|
roles: ["public-surface"],
|
|
origin: "test",
|
|
public_description: "test",
|
|
},
|
|
canonical_state: {
|
|
underlying_organization: {},
|
|
stable_dispositions: {},
|
|
behavioral_dimensions: {},
|
|
presentation_strategy: {},
|
|
voice_style: {},
|
|
situational_state: {},
|
|
values: [],
|
|
},
|
|
goals: [],
|
|
memories: {
|
|
episodic: [],
|
|
semantic: [],
|
|
relationship_summaries: [],
|
|
},
|
|
perceived_state_overlays: [],
|
|
},
|
|
],
|
|
relationships: [],
|
|
events: [],
|
|
scenes: [],
|
|
});
|
|
|
|
await originCache.put(documentDefinition, "epiphany.persona", payload);
|
|
const snapshot = registry.createSnapshotResponse(originCache, "snapshot-1");
|
|
await registry.applySnapshotResponse(targetCache, snapshot);
|
|
|
|
const roundTrip = targetCache.get(documentDefinition, "epiphany.persona");
|
|
assert.ok(roundTrip);
|
|
assert.equal(roundTrip?.schema_version, "ghostlight.agent_state.v0");
|
|
assert.equal(roundTrip?.agents[0]?.agent_id, "epiphany.persona");
|
|
} finally {
|
|
rmSync(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("CultNet raw replication preserves CultCache payload bytes for bit-compatible neighbors", async () => {
|
|
const tempDir = mkdtempSync(join(tmpdir(), "cultnetts-raw-"));
|
|
|
|
try {
|
|
const documentDefinition = defineDocumentType({
|
|
type: "ghostlight.agent-state",
|
|
schemaId: cultNetSchemas.ghostlightAgentStateSchema.$id,
|
|
schemaName: "ghostlight.agent-state",
|
|
schemaVersion: "ghostlight.agent_state.v0",
|
|
schema: z.custom<GhostlightAgentStateDocument>((value) => {
|
|
try {
|
|
validateGhostlightAgentState(value);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}),
|
|
});
|
|
|
|
const registry = new CultNetDocumentRegistry([
|
|
defineCultNetDocumentBinding({
|
|
definition: documentDefinition,
|
|
payloadSchemaVersion: "ghostlight.agent_state.v0",
|
|
}),
|
|
]);
|
|
|
|
const originCache = CultCache.builder()
|
|
.withDocumentType(documentDefinition)
|
|
.withGenericStore(new SingleFileMessagePackBackingStore(join(tempDir, "origin.msgpack")))
|
|
.build();
|
|
const targetCache = CultCache.builder()
|
|
.withDocumentType(documentDefinition)
|
|
.withGenericStore(new SingleFileMessagePackBackingStore(join(tempDir, "target.msgpack")))
|
|
.build();
|
|
|
|
const payload = validateGhostlightAgentState({
|
|
schema_version: "ghostlight.agent_state.v0",
|
|
world: {
|
|
world_id: "epiphany-face",
|
|
setting: "test harness",
|
|
time: { label: "now" },
|
|
canon_context: ["test"],
|
|
},
|
|
agents: [
|
|
{
|
|
agent_id: "epiphany.persona",
|
|
identity: {
|
|
name: "Persona",
|
|
roles: ["public-surface"],
|
|
origin: "test",
|
|
public_description: "test",
|
|
},
|
|
canonical_state: {
|
|
underlying_organization: {},
|
|
stable_dispositions: {},
|
|
behavioral_dimensions: {},
|
|
presentation_strategy: {},
|
|
voice_style: {},
|
|
situational_state: {},
|
|
values: [],
|
|
},
|
|
goals: [],
|
|
memories: {
|
|
episodic: [],
|
|
semantic: [],
|
|
relationship_summaries: [],
|
|
},
|
|
perceived_state_overlays: [],
|
|
},
|
|
],
|
|
relationships: [],
|
|
events: [],
|
|
scenes: [],
|
|
});
|
|
|
|
await originCache.put(documentDefinition, "epiphany.persona", payload);
|
|
const rawSnapshot = registry.createRawSnapshotResponse(originCache, "raw-snapshot-1");
|
|
assert.equal(rawSnapshot.documents[0]?.schemaId, cultNetSchemas.ghostlightAgentStateSchema.$id);
|
|
assert.equal(rawSnapshot.documents[0]?.recordKey, "epiphany.persona");
|
|
await registry.applyRawSnapshotResponse(targetCache, rawSnapshot);
|
|
|
|
const sourceEnvelope = originCache.getRequiredEnvelope(documentDefinition, "epiphany.persona");
|
|
const targetEnvelope = targetCache.getRequiredEnvelope(documentDefinition, "epiphany.persona");
|
|
assert.deepEqual(targetEnvelope.payload, sourceEnvelope.payload);
|
|
assert.equal(targetCache.getRequired(documentDefinition, "epiphany.persona").schema_version, "ghostlight.agent_state.v0");
|
|
} finally {
|
|
rmSync(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("CultNet interop slot compatibility defaults missing trailing fields and rejects mismatched slots", () => {
|
|
const note: InteropNote = {
|
|
schemaVersion: INTEROP_SCHEMA_VERSION,
|
|
documentId: "note:compat",
|
|
authorRuntimeId: "compat-peer",
|
|
title: "Compatibility",
|
|
body: "Missing trailing fields are allowed when declared defaults cover them.",
|
|
tags: ["compat"],
|
|
};
|
|
|
|
assert.deepEqual(createInteropFormatter().decode(createLegacyInteropNoteFormatter().encode(note)), {
|
|
...note,
|
|
tags: [],
|
|
});
|
|
assert.throws(
|
|
() => createInteropFormatter().decode(createMismatchedInteropNoteFormatter().encode(note)),
|
|
/Expected string/u,
|
|
);
|
|
});
|
|
|
|
test("Ghostlight contract mirror rejects nested payloads that violate the canonical schema", () => {
|
|
assert.throws(
|
|
() => validateGhostlightAgentState({
|
|
schema_version: "ghostlight.agent_state.v0",
|
|
world: {
|
|
world_id: "ghostlight-lab",
|
|
setting: "test",
|
|
time: { label: "now" },
|
|
canon_context: ["test"],
|
|
},
|
|
agents: [
|
|
{
|
|
identity: {
|
|
name: "Persona",
|
|
roles: ["public-surface"],
|
|
origin: "test",
|
|
public_description: "test",
|
|
},
|
|
canonical_state: {
|
|
underlying_organization: {},
|
|
stable_dispositions: {},
|
|
behavioral_dimensions: {},
|
|
presentation_strategy: {},
|
|
voice_style: {},
|
|
situational_state: {},
|
|
values: [],
|
|
},
|
|
goals: [],
|
|
memories: {
|
|
episodic: [],
|
|
semantic: [],
|
|
relationship_summaries: [],
|
|
},
|
|
perceived_state_overlays: [],
|
|
},
|
|
],
|
|
relationships: [],
|
|
events: [],
|
|
scenes: [],
|
|
}),
|
|
/agent_id/u,
|
|
);
|
|
});
|
|
|
|
test("Generated Ghostlight contracts can feed CultCacheTS directly without a Zod mirror", async () => {
|
|
const tempDir = mkdtempSync(join(tmpdir(), "cultnetts-generated-"));
|
|
|
|
try {
|
|
const documentDefinition = defineDocumentType({
|
|
type: "ghostlight.agent-state.generated",
|
|
schema: ghostlightAgentStateGeneratedContract,
|
|
global: true,
|
|
});
|
|
|
|
const store = new SingleFileMessagePackBackingStore(join(tempDir, "generated.msgpack"));
|
|
const cache = CultCache.builder()
|
|
.withDocumentType(documentDefinition)
|
|
.withGenericStore(store)
|
|
.build();
|
|
|
|
const payload: GhostlightAgentStateShape = {
|
|
schema_version: "ghostlight.agent_state.v0",
|
|
world: {
|
|
world_id: "ghostlight-lab",
|
|
setting: "test harness",
|
|
time: { label: "now" },
|
|
canon_context: ["test"],
|
|
},
|
|
agents: [
|
|
{
|
|
agent_id: "void",
|
|
identity: {
|
|
name: "Void",
|
|
roles: ["observer"],
|
|
origin: "test",
|
|
public_description: "test",
|
|
},
|
|
canonical_state: {
|
|
underlying_organization: {},
|
|
stable_dispositions: {},
|
|
behavioral_dimensions: {},
|
|
presentation_strategy: {},
|
|
voice_style: {},
|
|
situational_state: {},
|
|
values: [],
|
|
},
|
|
goals: [],
|
|
memories: {
|
|
episodic: [],
|
|
semantic: [],
|
|
relationship_summaries: [],
|
|
},
|
|
perceived_state_overlays: [],
|
|
},
|
|
],
|
|
relationships: [],
|
|
events: [],
|
|
scenes: [],
|
|
};
|
|
|
|
await cache.putGlobal(documentDefinition, payload);
|
|
const roundTrip = cache.getRequiredGlobal(documentDefinition);
|
|
assert.equal(validateGhostlightAgentStateGenerated(roundTrip), true);
|
|
assert.equal(roundTrip.schema_version, "ghostlight.agent_state.v0");
|
|
assert.equal(roundTrip.agents[0]?.agent_id, "void");
|
|
} finally {
|
|
rmSync(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|