Sixteen conflicted files resolved by the spec's rule: main owns payload ownership, bounds and the Clock seam; C2a owns persistence semantics and exact binding. MintStoredAt is internal static (previous, now); the cache and the store pass their cache's Clock. Package versions are main's. The cultmesh-ts payload-parse acceptance and its test are deleted under retire-schema-alias; peer snapshot reads take the schema's own id, else an id the definition lists as compatible. The held firstRecordAtKey fallback is removed: main's handles take definitions only, so nothing could reach it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
3061 lines
125 KiB
TypeScript
3061 lines
125 KiB
TypeScript
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { EventEmitter } from "node:events";
|
|
import { Duplex } from "node:stream";
|
|
import dgram, { type Socket } from "node:dgram";
|
|
import { rmSync, mkdtempSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import { networkInterfaces, tmpdir } from "node:os";
|
|
|
|
import { z } from "zod";
|
|
import { decode as decodeMsgpack, encode } from "@msgpack/msgpack";
|
|
import {
|
|
CultCache,
|
|
SchemaConflictError,
|
|
SingleFileMessagePackBackingStore,
|
|
defineDocumentType,
|
|
schemaIdentityOf,
|
|
} from "@gamecult/cultcache-ts";
|
|
|
|
import {
|
|
CultNetClientSecurityOptions,
|
|
CultNetDocumentRegistry,
|
|
CultNetPeer,
|
|
CultNetRudpReconnectLoop,
|
|
CultNetRudpSession,
|
|
CultNetRudpSocketTransportConnection,
|
|
CULTNET_RUDP_RELIABLE_SEND_WINDOW_PACKETS,
|
|
CultNetSchemaCatalog,
|
|
CultNetSchemaRegistry,
|
|
CultNetSecret,
|
|
CultNetServerSecurityOptions,
|
|
CultNetShardCatalog,
|
|
TcpFramedTransportConnection,
|
|
CultNetReconnectController,
|
|
cultNetSchemas,
|
|
cultNetBuiltinSchemaRegistry,
|
|
computeCultNetReconnectDelayMs,
|
|
createCultNetReconnectPolicy,
|
|
createTcpFramedCultNetPeer,
|
|
createTcpFramedTransportProfile,
|
|
createRudpTransportProfile,
|
|
defineCultNetDocumentBinding,
|
|
decodeRudpPacket,
|
|
type CultNetOperationRequestMessage,
|
|
type CultNetOperationResponseMessage,
|
|
type CultNetRudpPacket,
|
|
encodeCultNetMessageForWire,
|
|
encodeRudpPacket,
|
|
ghostlightAgentStateGeneratedContract,
|
|
parseCultNetMessage,
|
|
invokeCultNetOperation,
|
|
rudpClientBindHost,
|
|
startCultNetOperationServer,
|
|
validateGhostlightAgentStateGenerated,
|
|
validateGhostlightAgentState,
|
|
shardServes,
|
|
type CultNetLoginMessage,
|
|
type GhostlightAgentStateShape,
|
|
type GhostlightAgentStateDocument,
|
|
} from "../src";
|
|
import {
|
|
INTEROP_SCHEMA_VERSION,
|
|
createInteropFormatter,
|
|
createLegacyInteropNoteFormatter,
|
|
createMismatchedInteropNoteFormatter,
|
|
type InteropNote,
|
|
} from "./interop/cultnet-interop-shared";
|
|
|
|
test("CultNet validates the canonical CultMesh Verse catalog messages", () => {
|
|
const request = parseCultNetMessage({
|
|
schemaVersion: "cultmesh.verse_catalog_request.v0",
|
|
messageId: "catalog-1",
|
|
verseIds: ["sample.counter"],
|
|
transportVersion: "cultmesh.v1",
|
|
});
|
|
assert.equal(request.schemaVersion, "cultmesh.verse_catalog_request.v0");
|
|
|
|
const response = parseCultNetMessage({
|
|
schemaVersion: "cultmesh.verse_catalog_response.v0",
|
|
messageId: "catalog-1",
|
|
verses: [{
|
|
verseId: "sample.counter",
|
|
displayName: "Counter",
|
|
authorityModel: "OperatorCluster",
|
|
compatibility: {
|
|
transportVersion: "cultmesh.v1",
|
|
rulesHash: "counter-v1",
|
|
compatibleVerseIds: [],
|
|
requiredPluginIds: [],
|
|
optionalPluginIds: [],
|
|
},
|
|
discoveryEndpoints: ["wss://provider.example/cultmesh"],
|
|
authorityRuntimeIds: ["sample.counter-provider"],
|
|
authorityRoutes: [{
|
|
authorityRuntimeId: "sample.counter-provider",
|
|
endpoint: "wss://provider.example/cultmesh",
|
|
protocolIds: ["cultmesh.documents.v1"],
|
|
priority: 0,
|
|
generation: "provider-route-1",
|
|
}],
|
|
}],
|
|
});
|
|
assert.equal(response.schemaVersion, "cultmesh.verse_catalog_response.v0");
|
|
assert.equal(response.verses[0].authorityRuntimeIds[0], "sample.counter-provider");
|
|
assert.equal(response.verses[0].authorityRoutes![0].generation, "provider-route-1");
|
|
|
|
const session = parseCultNetMessage({
|
|
schemaVersion: "cultmesh.session_accepted.v2",
|
|
messageId: "session-1",
|
|
accepted: true,
|
|
verseId: "sample.counter",
|
|
authorityRuntimeId: "sample.counter-provider",
|
|
protocolId: "cultmesh.documents.v1",
|
|
routeGeneration: "provider-route-1",
|
|
clientNonce: "bm9uY2U=",
|
|
providerKeyId: "provider-key-1",
|
|
providerSignature: "c2lnbmF0dXJl",
|
|
});
|
|
assert.equal(session.schemaVersion, "cultmesh.session_accepted.v2");
|
|
|
|
assert.throws(() => parseCultNetMessage({
|
|
...response,
|
|
verses: [{ ...response.verses[0], compatibility: { ...response.verses[0].compatibility, rulesHash: "" } }],
|
|
}), /rulesHash/);
|
|
});
|
|
|
|
class FakeRudpReconnectTransport extends EventEmitter {
|
|
public connectCalls = 0;
|
|
public closeCalls = 0;
|
|
public lastPayload: Uint8Array | undefined;
|
|
|
|
public connect(payload = new Uint8Array()): void {
|
|
this.connectCalls += 1;
|
|
this.lastPayload = payload;
|
|
}
|
|
|
|
public close(): void {
|
|
this.closeCalls += 1;
|
|
this.emit("close");
|
|
}
|
|
}
|
|
|
|
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 };
|
|
}
|
|
|
|
/// Whether the packet's ack field and mask cover the sequence.
|
|
function acknowledges(packet: CultNetRudpPacket, sequence: number): boolean {
|
|
if (packet.ack === sequence) return true;
|
|
return packet.ack > sequence
|
|
&& packet.ack - sequence <= 32
|
|
&& (packet.ackMask & (1 << (packet.ack - sequence - 1))) !== 0;
|
|
}
|
|
|
|
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.equal(profile.transports[0]?.reconnectPolicy?.schemaVersion, "cultnet.reconnect_policy.v0");
|
|
assert.equal(profile.transports[0]?.reconnectPolicy?.baseDelayMs, 1_000);
|
|
assert.deepEqual(
|
|
profile.transports[0]?.channels.map((channel) => [channel.channelId, channel.delivery, channel.ordering]),
|
|
[
|
|
["schema", "reliable", "ordered"],
|
|
["latest", "unreliable", "sequenced"],
|
|
["realtime", "unreliable", "unordered"],
|
|
],
|
|
);
|
|
});
|
|
|
|
test("reconnect policy exposes the shared portable delay contract", () => {
|
|
const policy = createCultNetReconnectPolicy({ policyId: "rudp-default", maxAttempts: 8 });
|
|
|
|
assert.equal(policy.schemaVersion, "cultnet.reconnect_policy.v0");
|
|
assert.equal(policy.policyId, "rudp-default");
|
|
assert.equal(policy.maxAttempts, 8);
|
|
assert.equal(computeCultNetReconnectDelayMs(policy, 1), 1_000);
|
|
assert.equal(computeCultNetReconnectDelayMs(policy, 3, 17), 4_017);
|
|
assert.equal(computeCultNetReconnectDelayMs(policy, 9, 999), 30_250);
|
|
assert.equal(computeCultNetReconnectDelayMs(policy, 0, -5), 1_000);
|
|
});
|
|
|
|
test("reconnect controller schedules attempts and reset with the shared policy", () => {
|
|
const policy = createCultNetReconnectPolicy({ maxAttempts: 2 });
|
|
const controller = new CultNetReconnectController(policy);
|
|
|
|
const first = controller.recordFailure(10_000);
|
|
assert.deepEqual(first, {
|
|
attempt: 1,
|
|
shouldRetry: true,
|
|
delayMs: 1_000,
|
|
nextAttemptAtMs: 11_000,
|
|
exhausted: false,
|
|
});
|
|
assert.equal(controller.canAttempt(10_999), false);
|
|
assert.equal(controller.canAttempt(11_000), true);
|
|
|
|
const second = controller.recordFailure(11_000, 17);
|
|
assert.equal(second.attempt, 2);
|
|
assert.equal(second.delayMs, 2_017);
|
|
assert.equal(second.nextAttemptAtMs, 13_017);
|
|
assert.equal(second.shouldRetry, true);
|
|
|
|
const exhausted = controller.recordFailure(13_017);
|
|
assert.deepEqual(exhausted, {
|
|
attempt: 2,
|
|
shouldRetry: false,
|
|
delayMs: 0,
|
|
exhausted: true,
|
|
});
|
|
assert.equal(controller.exhausted, true);
|
|
assert.equal(controller.canAttempt(99_000), false);
|
|
|
|
controller.reset();
|
|
assert.equal(controller.attempt, 0);
|
|
assert.equal(controller.nextAttemptAtMs, undefined);
|
|
assert.equal(controller.exhausted, false);
|
|
assert.equal(controller.canAttempt(99_000), true);
|
|
});
|
|
|
|
test("rudp reconnect loop consumes the shared reconnect controller", () => {
|
|
let nowMs = 10_000;
|
|
let capturedTimer: (() => void) | undefined;
|
|
const transports: FakeRudpReconnectTransport[] = [];
|
|
const loop = new CultNetRudpReconnectLoop({
|
|
reconnectPolicy: createCultNetReconnectPolicy({ maxAttempts: 2 }),
|
|
connectPayload: Buffer.from("join", "utf8"),
|
|
createTransport: () => {
|
|
const transport = new FakeRudpReconnectTransport();
|
|
transports.push(transport);
|
|
return transport;
|
|
},
|
|
nowMs: () => nowMs,
|
|
jitterMs: () => 17,
|
|
setTimer: (callback, delayMs) => {
|
|
assert.equal(delayMs, 1_017);
|
|
capturedTimer = callback;
|
|
return "timer";
|
|
},
|
|
clearTimer: () => {
|
|
capturedTimer = undefined;
|
|
},
|
|
});
|
|
|
|
const first = loop.start() as FakeRudpReconnectTransport;
|
|
assert.equal(first.connectCalls, 1);
|
|
assert.equal(Buffer.from(first.lastPayload ?? []).toString("utf8"), "join");
|
|
|
|
first.emit("close");
|
|
assert.equal(loop.reconnectController.attempt, 1);
|
|
assert.equal(loop.reconnectController.nextAttemptAtMs, 11_017);
|
|
assert.equal(typeof capturedTimer, "function");
|
|
|
|
nowMs = 11_017;
|
|
capturedTimer?.();
|
|
assert.equal(transports.length, 2);
|
|
assert.equal(transports[1]?.connectCalls, 1);
|
|
|
|
loop.markConnected();
|
|
assert.equal(loop.reconnectController.attempt, 0);
|
|
|
|
loop.stop();
|
|
assert.equal(transports[1]?.closeCalls, 1);
|
|
assert.equal(loop.transport, undefined);
|
|
});
|
|
|
|
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]);
|
|
|
|
const retransmittedAccept = server.acceptConnect(connect, 15, Buffer.from("ok", "utf8"));
|
|
assert.deepEqual(
|
|
encodeRudpPacket(retransmittedAccept),
|
|
encodeRudpPacket(accept),
|
|
"a retransmitted Connect reuses the pending Accept packet",
|
|
);
|
|
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.sequence, 0);
|
|
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(receiver.acceptConnect(sender.createConnect(0), 0), 0);
|
|
|
|
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, [11, 12, 13]);
|
|
|
|
receiver.receive(first);
|
|
receiver.receive(third);
|
|
const ackWithGap = receiver.createAck();
|
|
assert.equal(ackWithGap.ack, 13);
|
|
assert.equal(ackWithGap.ackMask, 0b110);
|
|
sender.receive(ackWithGap);
|
|
assert.deepEqual(sender.pendingReliableSequences, [12]);
|
|
|
|
receiver.receive(second);
|
|
const fullAck = receiver.createAck();
|
|
assert.equal(fullAck.ack, 13);
|
|
assert.equal(fullAck.ackMask, 0b111);
|
|
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(new CultNetRudpSession({ connectionId: 99, initialSequence: 900 }).acceptConnect(session.createConnect(0), 0), 0);
|
|
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 lossy packets cannot create reliable ordered gaps", () => {
|
|
const client = new CultNetRudpSession({ connectionId: 198, initialSequence: 1 });
|
|
const server = new CultNetRudpSession({ connectionId: 198, initialSequence: 100 });
|
|
const connect = client.createConnect(0);
|
|
const accept = server.acceptConnect(connect, 1);
|
|
client.receive(accept, 2);
|
|
|
|
const ping = client.createPing(Buffer.from("pulse"));
|
|
const pong = server.receive(ping, 3).reply!;
|
|
const realtime = server.send("realtime", Buffer.from("discarded realtime"));
|
|
const latest = server.send("latest", Buffer.from("discarded latest state"), { sequenced: true });
|
|
const schema = server.send("schema", Buffer.from("committed response"), { reliable: true, ordered: true });
|
|
|
|
assert.equal(ping.sequence, 0);
|
|
assert.equal(pong.sequence, 0);
|
|
assert.equal(realtime.sequence, 0);
|
|
assert.equal(latest.sequence, 1);
|
|
assert.equal(schema.sequence, accept.sequence + 1);
|
|
assert.deepEqual(client.receive(schema, 4).delivered.map(frame => Buffer.from(frame.payload).toString("utf8")), [
|
|
"committed response",
|
|
]);
|
|
});
|
|
|
|
test("rudp unreliable sequenced delivery is scoped to its channel", () => {
|
|
const sender = new CultNetRudpSession({ connectionId: 197, initialSequence: 50 });
|
|
const receiver = new CultNetRudpSession({ connectionId: 197, initialSequence: 100 });
|
|
const connect = sender.createConnect(0);
|
|
const accept = receiver.acceptConnect(connect, 1);
|
|
sender.receive(accept, 2);
|
|
const older = sender.send("latest", Buffer.from("older"), { sequenced: true });
|
|
const newer = sender.send("latest", Buffer.from("newer"), { sequenced: true });
|
|
|
|
assert.equal(older.sequence, 1);
|
|
assert.equal(newer.sequence, 2);
|
|
assert.deepEqual(receiver.receive(newer, 3).delivered.map(frame => Buffer.from(frame.payload).toString("utf8")), ["newer"]);
|
|
assert.deepEqual(receiver.receive(older, 4).delivered, []);
|
|
});
|
|
|
|
test("rudp rejects unreliable ordered delivery", () => {
|
|
const session = new CultNetRudpSession({ connectionId: 196 });
|
|
session.receive(new CultNetRudpSession({ connectionId: 196, initialSequence: 900 }).acceptConnect(session.createConnect(0), 0), 0);
|
|
assert.throws(
|
|
() => session.send("schema", Buffer.from("cannot order what will not retransmit"), { ordered: true }),
|
|
/ordered delivery requires reliability/,
|
|
);
|
|
});
|
|
|
|
test("rudp session bounds pending reliable packets before enqueue", () => {
|
|
const session = new CultNetRudpSession({ connectionId: 102, initialSequence: 1, maxPendingReliablePackets: 2 });
|
|
session.receive(new CultNetRudpSession({ connectionId: 102, initialSequence: 900 }).acceptConnect(session.createConnect(0), 0), 0);
|
|
|
|
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, [2, 3]);
|
|
|
|
const fragmented = new CultNetRudpSession({ connectionId: 103, initialSequence: 1, maxPendingReliablePackets: 3 });
|
|
fragmented.receive(new CultNetRudpSession({ connectionId: 103, initialSequence: 900 }).acceptConnect(fragmented.createConnect(0), 0), 0);
|
|
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(receiver.acceptConnect(sender.createConnect(0), 0));
|
|
|
|
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 skips received control packets while ordering schema payloads", () => {
|
|
const sender = new CultNetRudpSession({ connectionId: 124, initialSequence: 1 });
|
|
const receiver = new CultNetRudpSession({ connectionId: 124, initialSequence: 100 });
|
|
sender.receive(receiver.acceptConnect(sender.createConnect(0), 0), 0);
|
|
|
|
const first = sender.send("schema", Buffer.from("first"), { reliable: true, ordered: true });
|
|
const control = sender.createAck();
|
|
const second = sender.send("schema", Buffer.from("second"), { reliable: true, ordered: true });
|
|
|
|
assert.equal(control.sequence, 0);
|
|
assert.equal(second.sequence, first.sequence + 1);
|
|
|
|
assert.deepEqual(receiver.receive(first).delivered.map((frame) => Buffer.from(frame.payload).toString("utf8")), ["first"]);
|
|
assert.deepEqual(receiver.receive(control).delivered, []);
|
|
assert.deepEqual(receiver.receive(second).delivered.map((frame) => Buffer.from(frame.payload).toString("utf8")), ["second"]);
|
|
});
|
|
|
|
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(receiver.acceptConnect(sender.createConnect(0), 0), 0);
|
|
|
|
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 TCP helper constructs a transport-backed peer with profile metadata", async () => {
|
|
const { a, b } = createDuplexPair();
|
|
const sender = createTcpFramedCultNetPeer(a, {
|
|
runtimeId: "tcp-helper-left",
|
|
wireContract: "cultnet.schema.v0",
|
|
transportId: "helper-left",
|
|
host: "127.0.0.1",
|
|
port: 17777,
|
|
maxPayloadBytes: 64 * 1024,
|
|
});
|
|
const receiver = createTcpFramedCultNetPeer(b, {
|
|
runtimeId: "tcp-helper-right",
|
|
wireContract: "cultnet.schema.v0",
|
|
});
|
|
|
|
assert.equal(sender.transportProfile?.runtimeId, "tcp-helper-left");
|
|
assert.equal(sender.transportProfile?.transports[0]?.transportId, "helper-left");
|
|
assert.equal(sender.transportProfile?.transports[0]?.protocol, "tcp_framed");
|
|
assert.equal(sender.transportProfile?.transports[0]?.host, "127.0.0.1");
|
|
assert.equal(sender.transportProfile?.transports[0]?.port, 17777);
|
|
assert.equal(sender.transportProfile?.transports[0]?.channels[0]?.channelId, "schema");
|
|
assert.equal(sender.transportProfile?.transports[0]?.channels[0]?.maxPayloadBytes, 64 * 1024);
|
|
|
|
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: "tcp-helper-sender",
|
|
runtimeKind: "node-worker",
|
|
});
|
|
});
|
|
|
|
assert.equal(message.schemaVersion, "cultnet.hello.v0");
|
|
if (message.schemaVersion === "cultnet.hello.v0") {
|
|
assert.equal(message.runtimeId, "tcp-helper-sender");
|
|
}
|
|
|
|
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,
|
|
[
|
|
new TextEncoder().encode("nonce"),
|
|
new TextEncoder().encode("auth"),
|
|
new TextEncoder().encode("password"),
|
|
],
|
|
]);
|
|
|
|
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"]);
|
|
const shardCatalogRequest = response.schemas.find((schema) => schema.schemaVersion === "cultnet.shard_catalog_request.v0");
|
|
assert.ok(shardCatalogRequest);
|
|
assert.equal(shardCatalogRequest?.schemaId, cultNetSchemas.shardCatalogRequestSchema.$id);
|
|
assert.deepEqual(shardCatalogRequest?.wireContracts, ["cultnet.schema.v0"]);
|
|
const shardCatalogResponse = response.schemas.find((schema) => schema.schemaVersion === "cultnet.shard_catalog_response.v0");
|
|
assert.ok(shardCatalogResponse);
|
|
assert.equal(shardCatalogResponse?.schemaId, cultNetSchemas.shardCatalogResponseSchema.$id);
|
|
assert.deepEqual(shardCatalogResponse?.wireContracts, ["cultnet.schema.v0"]);
|
|
assert.equal(
|
|
cultNetSchemas.transportProfileSchema.properties.transports.items.properties.reconnectPolicy.properties.schemaVersion.const,
|
|
"cultnet.reconnect_policy.v0",
|
|
);
|
|
});
|
|
|
|
test("CultNet schema catalog applies remote descriptors and notifies watchers", () => {
|
|
const catalog = new CultNetSchemaCatalog();
|
|
let watchedSchemaId: string | undefined;
|
|
const unsubscribe = catalog.watch((descriptor) => {
|
|
watchedSchemaId = descriptor.schemaId;
|
|
});
|
|
const response = cultNetBuiltinSchemaRegistry.createCatalogResponse({
|
|
schemaVersion: "cultnet.schema_catalog_request.v0",
|
|
messageId: "catalog-apply",
|
|
includeSchemaJson: true,
|
|
kinds: ["document_payload"],
|
|
});
|
|
|
|
const applied = catalog.applyResponse(response);
|
|
|
|
assert.ok(applied.some((descriptor) => descriptor.documentType === "ghostlight.agent-state"));
|
|
assert.equal(watchedSchemaId, applied.at(-1)?.schemaId);
|
|
assert.equal(catalog.get(applied[0]!.schemaId)?.schemaJson, undefined);
|
|
assert.equal(typeof catalog.get(applied[0]!.schemaId, { includeSchemaJson: true })?.schemaJson, "string");
|
|
assert.deepEqual(
|
|
catalog.list({ kinds: ["document_payload"] }).map((descriptor) => descriptor.kind),
|
|
catalog.list().map((descriptor) => descriptor.kind),
|
|
);
|
|
|
|
unsubscribe();
|
|
});
|
|
|
|
test("CultNet peer can request and sync schema catalogs by message id", async () => {
|
|
const { a, b } = createDuplexPair();
|
|
const requester = createTcpFramedCultNetPeer(a, {
|
|
runtimeId: "catalog-requester",
|
|
wireContract: "cultnet.schema.v0",
|
|
});
|
|
const responder = createTcpFramedCultNetPeer(b, {
|
|
runtimeId: "catalog-responder",
|
|
wireContract: "cultnet.schema.v0",
|
|
});
|
|
|
|
responder.on("message", (message) => {
|
|
if (message.schemaVersion === "cultnet.schema_catalog_request.v0") {
|
|
responder.sendSchemaCatalogResponse(cultNetBuiltinSchemaRegistry.createCatalogResponse(message));
|
|
}
|
|
});
|
|
|
|
const synced = new CultNetSchemaCatalog();
|
|
const applied = await requester.syncSchemaCatalog(synced, {
|
|
messageId: "peer-schema-catalog",
|
|
includeSchemaJson: true,
|
|
kinds: ["document_payload"],
|
|
timeoutMs: 1_000,
|
|
});
|
|
|
|
assert.ok(applied.some((descriptor) => descriptor.documentType === "ghostlight.agent-state"));
|
|
assert.equal(synced.get(applied[0]!.schemaId)?.schemaJson, undefined);
|
|
assert.equal(typeof synced.get(applied[0]!.schemaId, { includeSchemaJson: true })?.schemaJson, "string");
|
|
|
|
requester.close();
|
|
responder.close();
|
|
});
|
|
|
|
test("CultNet shard catalog filters descriptors and answers catalog requests", () => {
|
|
const catalog = new CultNetShardCatalog();
|
|
let watchedShardId: string | undefined;
|
|
const unsubscribe = catalog.watch((descriptor) => {
|
|
watchedShardId = descriptor.shardId;
|
|
});
|
|
|
|
catalog.upsert({
|
|
shardId: "notes-a",
|
|
ownerRuntimeId: "ts-owner",
|
|
epoch: 7,
|
|
isPrimary: true,
|
|
schemaIds: ["note.v1"],
|
|
keyPrefix: "note:",
|
|
primaryEndpoints: ["rudp://127.0.0.1:4100"],
|
|
authorityLeaseId: "lease-a",
|
|
});
|
|
catalog.upsert({
|
|
shardId: "profiles-a",
|
|
ownerRuntimeId: "ts-owner",
|
|
epoch: 3,
|
|
schemaIds: ["profile.v1"],
|
|
keyPrefix: "profile:",
|
|
});
|
|
|
|
assert.equal(watchedShardId, "profiles-a");
|
|
assert.equal(shardServes(catalog.get("notes-a")!, { schemaId: "note.v1", recordKey: "note:1" }), true);
|
|
assert.equal(shardServes(catalog.get("notes-a")!, { schemaId: "note", recordKey: "note:1" }), true);
|
|
assert.equal(shardServes(catalog.get("notes-a")!, { schemaId: "profile.v1", recordKey: "note:1" }), false);
|
|
assert.deepEqual(catalog.list({ schemaIds: ["note.v1"], recordKeys: ["note:1"] }).map((shard) => shard.shardId), ["notes-a"]);
|
|
assert.deepEqual(catalog.list({ schemaIds: ["note"], recordKeys: ["note:1"] }).map((shard) => shard.shardId), ["notes-a"]);
|
|
|
|
const response = catalog.createCatalogResponse({
|
|
schemaVersion: "cultnet.shard_catalog_request.v0",
|
|
messageId: "shards",
|
|
schemaIds: ["note"],
|
|
recordKeys: ["note:2"],
|
|
});
|
|
assert.equal(response.schemaVersion, "cultnet.shard_catalog_response.v0");
|
|
assert.equal(response.shards[0]?.shardId, "notes-a");
|
|
|
|
const parsed = parseCultNetMessage(response);
|
|
assert.equal(parsed.schemaVersion, "cultnet.shard_catalog_response.v0");
|
|
if (parsed.schemaVersion === "cultnet.shard_catalog_response.v0") {
|
|
assert.equal(parsed.shards[0]?.ownerRuntimeId, "ts-owner");
|
|
}
|
|
|
|
unsubscribe();
|
|
});
|
|
|
|
test("CultNet peer can request and sync shard catalogs by message id", async () => {
|
|
const { a, b } = createDuplexPair();
|
|
const requester = createTcpFramedCultNetPeer(a, {
|
|
runtimeId: "shard-requester",
|
|
wireContract: "cultnet.schema.v0",
|
|
});
|
|
const responder = createTcpFramedCultNetPeer(b, {
|
|
runtimeId: "shard-responder",
|
|
wireContract: "cultnet.schema.v0",
|
|
});
|
|
const remote = new CultNetShardCatalog();
|
|
remote.upsert({
|
|
shardId: "notes-peer",
|
|
ownerRuntimeId: "shard-responder",
|
|
epoch: 1,
|
|
isPrimary: true,
|
|
schemaIds: ["note.v1"],
|
|
keyPrefix: "note:",
|
|
primaryEndpoints: ["rudp://127.0.0.1:4200"],
|
|
});
|
|
|
|
responder.on("message", (message) => {
|
|
if (message.schemaVersion === "cultnet.shard_catalog_request.v0") {
|
|
responder.sendShardCatalogResponse(remote.createCatalogResponse(message));
|
|
}
|
|
});
|
|
|
|
const synced = new CultNetShardCatalog();
|
|
const applied = await requester.syncShardCatalog(synced, {
|
|
messageId: "peer-shards",
|
|
schemaIds: ["note"],
|
|
recordKeys: ["note:local"],
|
|
timeoutMs: 1_000,
|
|
});
|
|
|
|
assert.equal(applied[0]?.shardId, "notes-peer");
|
|
assert.equal(synced.get("notes-peer")?.primaryEndpoints?.[0], "rudp://127.0.0.1:4200");
|
|
|
|
requester.close();
|
|
responder.close();
|
|
});
|
|
|
|
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 raw replication under a compatible schema id leaves a store that reopens and takes the next write", async () => {
|
|
const tempDir = mkdtempSync(join(tmpdir(), "cultnetts-raw-compatible-"));
|
|
|
|
try {
|
|
const definition = defineDocumentType({
|
|
type: "tests.raw-compatible",
|
|
schema: z.object({ name: z.string() }),
|
|
schemaId: "tests.raw-compatible.current",
|
|
schemaName: "tests.raw-compatible",
|
|
schemaVersion: "tests.raw_compatible.v1",
|
|
compatibleSchemaIds: ["tests.raw-compatible.current", "tests.raw-compatible.older"],
|
|
});
|
|
const registry = new CultNetDocumentRegistry([
|
|
defineCultNetDocumentBinding({ definition, payloadSchemaVersion: "tests.raw_compatible.v1" }),
|
|
]);
|
|
const open = async (): Promise<CultCache> => {
|
|
const cache = CultCache.builder()
|
|
.withDocumentType(definition)
|
|
.withGenericStore(new SingleFileMessagePackBackingStore(join(tempDir, "target.msgpack")))
|
|
.build();
|
|
await cache.pullAllBackingStores();
|
|
return cache;
|
|
};
|
|
|
|
const origin = CultCache.builder().withDocumentType(definition).build();
|
|
await origin.put(definition, "seed", { name: "seed" });
|
|
const payload = origin.getRequiredEnvelope(definition, "seed").payload;
|
|
|
|
const target = await open();
|
|
await registry.applyRawDocumentPutMessage(target, {
|
|
schemaVersion: "cultnet.document_put_raw.v0",
|
|
messageId: "raw-compatible-1",
|
|
document: {
|
|
schemaId: "tests.raw-compatible.older",
|
|
recordKey: "replicated",
|
|
storedAt: "2026-09-30T00:00:00.0000000Z",
|
|
payloadEncoding: "messagepack",
|
|
payload,
|
|
},
|
|
} as Parameters<CultNetDocumentRegistry["applyRawDocumentPutMessage"]>[1]);
|
|
|
|
const reopened = await open();
|
|
assert.deepEqual(reopened.getRequired(definition, "replicated"), { name: "seed" });
|
|
await reopened.put(definition, "next", { name: "next" });
|
|
assert.deepEqual((await open()).getRequired(definition, "next"), { name: "next" });
|
|
} finally {
|
|
rmSync(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("operation envelopes preserve typed service routing and payload correlation", () => {
|
|
const request = parseCultNetMessage({
|
|
schemaVersion: "cultnet.operation_request.v0",
|
|
messageId: "plugin-42",
|
|
serviceId: "sai.vn",
|
|
operation: "project",
|
|
payloadSchema: "gamecult.eve.plugin_abi.request.v1",
|
|
payloadEncoding: "messagepack-base64",
|
|
payload: "gaZzY2hlbWHEJ2dhbWVjdWx0LmV2ZS5wbHVnaW5fYWJpLnJlcXVlc3QudjE=",
|
|
sourceRuntimeId: "eve-unity",
|
|
});
|
|
assert.equal(request.schemaVersion, "cultnet.operation_request.v0");
|
|
assert.equal(request.messageId, "plugin-42");
|
|
assert.equal(request.serviceId, "sai.vn");
|
|
|
|
const response = parseCultNetMessage({
|
|
schemaVersion: "cultnet.operation_response.v0",
|
|
messageId: "plugin-42",
|
|
serviceId: "sai.vn",
|
|
operation: "project",
|
|
status: "ok",
|
|
payloadSchema: "gamecult.eve.plugin_abi.response.v1",
|
|
payloadEncoding: "messagepack-base64",
|
|
payload: "gaZzdGF0dXOkb2s=",
|
|
diagnostics: [],
|
|
});
|
|
assert.equal(response.schemaVersion, "cultnet.operation_response.v0");
|
|
assert.equal(response.messageId, request.messageId);
|
|
});
|
|
|
|
test("rudp session advances large fragment sets through a bounded reliable window", () => {
|
|
const connectionId = 457;
|
|
const sender = new CultNetRudpSession({ connectionId, initialSequence: 1, resendDelayMs: 25 });
|
|
const receiver = new CultNetRudpSession({ connectionId, initialSequence: 100, resendDelayMs: 25 });
|
|
sender.receive(receiver.acceptConnect(sender.createConnect(0), 0), 0);
|
|
|
|
const fragmentCount = CULTNET_RUDP_RELIABLE_SEND_WINDOW_PACKETS + 17;
|
|
const payload = Uint8Array.from(
|
|
{ length: fragmentCount * 8 },
|
|
(_, index) => index % 251,
|
|
);
|
|
const wire = sender.sendMany("schema", payload, {
|
|
reliable: true,
|
|
ordered: true,
|
|
nowMs: 1,
|
|
maxFragmentBytes: 8,
|
|
});
|
|
assert.equal(wire.length, CULTNET_RUDP_RELIABLE_SEND_WINDOW_PACKETS);
|
|
const oldestSequence = wire[0]!.sequence;
|
|
assert.equal(sender.pendingReliableSequences.length, wire.length);
|
|
assert.equal(sender.queuedReliablePacketCount, 17);
|
|
|
|
const delivered = [];
|
|
while (wire.length > 0) {
|
|
const admitted = wire.length;
|
|
for (let index = 0; index < admitted; index += 1) {
|
|
const packet = wire.shift()!;
|
|
delivered.push(...receiver.receive(packet, 2).delivered);
|
|
}
|
|
const acknowledged = sender.receive(receiver.createAck(), 3);
|
|
wire.push(...(acknowledged.readyToSend ?? []));
|
|
}
|
|
|
|
assert.equal(sender.outstandingReliablePacketCount, 0);
|
|
assert.equal(delivered.length, 1);
|
|
assert.deepEqual(delivered[0]?.payload, payload);
|
|
const oldAck = receiver.createAckForReceived(oldestSequence);
|
|
assert.equal(oldAck.ack, oldestSequence);
|
|
assert.equal(oldAck.ackMask, 0);
|
|
});
|
|
|
|
// The sender's flow window: a reliable packet goes on the wire only while its sequence is at most 1,023 above the
|
|
// lowest unacked one and the payload above that sequence stays within 4 MiB.
|
|
const FLOW_CONNECTION_ID = 0x464c4f57;
|
|
const MIB = 1024 * 1024;
|
|
|
|
function flowSession(initialSequence: number): CultNetRudpSession {
|
|
return new CultNetRudpSession({ connectionId: FLOW_CONNECTION_ID, initialSequence, resendDelayMs: 25 });
|
|
}
|
|
|
|
// A sender and a receiver that have shaken hands: the receiver's watermark is seeded by the sender's Connect, and the
|
|
// sender's Connect is acknowledged, so nothing is pending on the sender.
|
|
function connectedFlowPair(senderInitialSequence: number, receiverInitialSequence: number): [CultNetRudpSession, CultNetRudpSession] {
|
|
const sender = flowSession(senderInitialSequence);
|
|
const receiver = flowSession(receiverInitialSequence);
|
|
sender.receive(receiver.acceptConnect(sender.createConnect(0), 0), 0);
|
|
return [sender, receiver];
|
|
}
|
|
|
|
function connectedFlowSession(initialSequence: number): CultNetRudpSession {
|
|
return connectedFlowPair(initialSequence, 900)[0];
|
|
}
|
|
|
|
function flowSend(session: CultNetRudpSession, payloadBytes: number): CultNetRudpPacket[] {
|
|
return session.sendMany("state", new Uint8Array(payloadBytes), { reliable: true });
|
|
}
|
|
|
|
function flowAck(sequence: number): CultNetRudpPacket {
|
|
return { packetType: "ack", connectionId: FLOW_CONNECTION_ID, sequence: 0, ack: sequence, ackMask: 0, channelId: "control" };
|
|
}
|
|
|
|
const sequencesOf = (packets: CultNetRudpPacket[]): number[] => packets.map((packet) => packet.sequence);
|
|
|
|
function loseGAndFillTheSpan(session: CultNetRudpSession): CultNetRudpPacket {
|
|
const g = flowSend(session, 1)[0]!;
|
|
for (let offset = 1; offset <= 1023; offset++) {
|
|
assert.deepEqual(sequencesOf(flowSend(session, 1)), [g.sequence + offset]);
|
|
session.receive(flowAck(g.sequence + offset), 1);
|
|
}
|
|
return g;
|
|
}
|
|
|
|
test("a lost packet holds the sender 1023 sequences ahead and is still delivered", () => {
|
|
const [sender, receiver] = connectedFlowPair(1, 100);
|
|
const g = flowSend(sender, 1)[0]!;
|
|
let admittedAfterG = 0;
|
|
for (let index = 0; index < 4200; index++) {
|
|
for (const packet of flowSend(sender, 1)) {
|
|
admittedAfterG++;
|
|
receiver.receive(packet, 1);
|
|
sender.receive(receiver.createAck(), 1);
|
|
}
|
|
}
|
|
assert.equal(admittedAfterG, 1023);
|
|
assert.equal(sender.queuedReliablePacketCount, 4200 - 1023);
|
|
|
|
const retransmit = sender.dueResends(1000).find((packet) => packet.sequence === g.sequence)!;
|
|
const delivered = receiver.receive(retransmit, 1000).delivered;
|
|
assert.deepEqual(delivered.map((frame) => frame.sequence), [g.sequence]);
|
|
|
|
const promoted = sender.receive(receiver.createAckForReceived(g.sequence), 1001).readyToSend ?? [];
|
|
assert.equal(promoted[0]!.sequence, g.sequence + 1024);
|
|
});
|
|
|
|
test("a direct send past the span is refused without consuming a sequence", () => {
|
|
const sender = connectedFlowSession(1);
|
|
const g = loseGAndFillTheSpan(sender);
|
|
assert.throws(() => sender.send("state", new Uint8Array(1), { reliable: true }));
|
|
assert.equal(sender.queuedReliablePacketCount, 0);
|
|
sender.receive(flowAck(g.sequence), 2);
|
|
assert.equal(sender.send("state", new Uint8Array(1), { reliable: true }).sequence, g.sequence + 1024);
|
|
});
|
|
|
|
test("payload above the lowest unacked sequence is bounded by 4 MiB", () => {
|
|
const sender = connectedFlowSession(1);
|
|
const g = flowSend(sender, MIB)[0]!;
|
|
// The lowest unacked packet's own bytes do not count: four more MiB fit above it.
|
|
for (let index = 0; index < 4; index++) assert.equal(flowSend(sender, MIB).length, 1);
|
|
assert.equal(flowSend(sender, MIB).length, 0);
|
|
assert.equal(flowSend(sender, MIB).length, 0);
|
|
assert.equal(sender.queuedReliablePacketCount, 2);
|
|
|
|
// Acking g moves the floor up one packet: exactly one MiB more fits.
|
|
const promoted = sender.receive(flowAck(g.sequence), 1).readyToSend ?? [];
|
|
assert.deepEqual(sequencesOf(promoted), [g.sequence + 5]);
|
|
assert.equal(sender.queuedReliablePacketCount, 1);
|
|
});
|
|
|
|
test("a packet larger than the window goes out alone and waits behind anything unacked", () => {
|
|
assert.equal(flowSend(connectedFlowSession(1), 5 * MIB).length, 1);
|
|
const sender = connectedFlowSession(1);
|
|
const g = flowSend(sender, 1)[0]!;
|
|
assert.equal(flowSend(sender, 5 * MIB).length, 0);
|
|
assert.deepEqual(sequencesOf(sender.receive(flowAck(g.sequence), 1).readyToSend ?? []), [g.sequence + 1]);
|
|
});
|
|
|
|
test("a small packet does not overtake a queued large one", () => {
|
|
const sender = connectedFlowSession(1);
|
|
const g = flowSend(sender, 1)[0]!;
|
|
assert.equal(flowSend(sender, 3 * MIB).length, 1);
|
|
assert.equal(flowSend(sender, 2 * MIB).length, 0);
|
|
// A direct send refuses instead of queueing behind it, and takes no sequence.
|
|
assert.throws(() => sender.send("state", new Uint8Array(1), { reliable: true }));
|
|
assert.equal(sender.queuedReliablePacketCount, 1);
|
|
// One byte would fit above g, but the 2 MiB packet is ahead of it.
|
|
assert.equal(flowSend(sender, 1).length, 0);
|
|
assert.deepEqual(sequencesOf(sender.receive(flowAck(g.sequence), 1).readyToSend ?? []), [g.sequence + 2, g.sequence + 3]);
|
|
});
|
|
|
|
test("acknowledged bytes above a lost packet still count against 4 MiB", () => {
|
|
const sender = connectedFlowSession(1);
|
|
const g = flowSend(sender, 1)[0]!;
|
|
// g is lost. Each 1 MiB frame above it is delivered and acknowledged, and the receiver still holds it behind the
|
|
// gap, so it keeps counting.
|
|
for (let offset = 1; offset <= 4; offset++) {
|
|
assert.deepEqual(sequencesOf(flowSend(sender, MIB)), [g.sequence + offset]);
|
|
sender.receive(flowAck(g.sequence + offset), 1);
|
|
}
|
|
assert.equal(flowSend(sender, MIB).length, 0);
|
|
assert.equal(sender.queuedReliablePacketCount, 1);
|
|
assert.throws(() => sender.send("state", new Uint8Array(MIB), { reliable: true }));
|
|
|
|
// g arrives: nothing is held behind a gap any more, and the queue drains.
|
|
assert.deepEqual(sequencesOf(sender.receive(flowAck(g.sequence), 2).readyToSend ?? []), [g.sequence + 5]);
|
|
// The bytes above the old gap no longer count: another MiB fits.
|
|
assert.deepEqual(sequencesOf(flowSend(sender, MIB)), [g.sequence + 6]);
|
|
});
|
|
|
|
test("acknowledged bytes count against the lowest unacked sequence as it advances", () => {
|
|
const sender = connectedFlowSession(1);
|
|
const g = flowSend(sender, 1)[0]!;
|
|
const h = flowSend(sender, 1)[0]!;
|
|
for (let index = 0; index < 3; index++) flowSend(sender, MIB);
|
|
// g and the 3 MiB above h are acknowledged; h is lost.
|
|
for (const offset of [2, 3, 4, 0]) sender.receive(flowAck(g.sequence + offset), 1);
|
|
assert.deepEqual(sequencesOf(flowSend(sender, MIB)), [g.sequence + 5]);
|
|
// 4 MiB sit above h, acknowledged or not: one more byte does not fit.
|
|
assert.equal(flowSend(sender, 1).length, 0);
|
|
// h arrives, the floor moves to g + 5, and the byte fits.
|
|
assert.deepEqual(sequencesOf(sender.receive(flowAck(h.sequence), 2).readyToSend ?? []), [g.sequence + 6]);
|
|
});
|
|
|
|
// A server that accepts a fresh Connect may send before its Accept is acknowledged, so no acknowledgement of the new
|
|
// generation has moved the floor yet. Bytes the ended generation had acknowledged above its own lost packet belong to
|
|
// no receiver's hold any more and must not count.
|
|
test("a new generation on the server does not inherit acknowledged bytes", () => {
|
|
const server = flowSession(500);
|
|
const accept = server.acceptConnect(flowSession(1).createConnect(0), 0);
|
|
server.receive(flowAck(accept.sequence), 0);
|
|
const g = flowSend(server, 1)[0]!;
|
|
for (let offset = 1; offset <= 4; offset++) {
|
|
assert.deepEqual(sequencesOf(flowSend(server, MIB)), [g.sequence + offset]);
|
|
server.receive(flowAck(g.sequence + offset), 1);
|
|
}
|
|
assert.equal(flowSend(server, MIB).length, 0);
|
|
|
|
const nextAccept = server.acceptConnect(flowSession(200_000).createConnect(2), 2);
|
|
assert.equal(nextAccept.packetType, "accept");
|
|
// The Accept is unacknowledged and the only thing pending: 1 MiB fits above it.
|
|
assert.equal(server.send("state", new Uint8Array(MIB), { reliable: true }).sequence, nextAccept.sequence + 1);
|
|
});
|
|
|
|
test("CultNet contracts encode legacy bytes without Node Buffer authority", () => {
|
|
const originalBuffer = globalThis.Buffer;
|
|
try {
|
|
Object.defineProperty(globalThis, "Buffer", { configurable: true, value: undefined });
|
|
const message = parseCultNetMessage({
|
|
schemaVersion: "cultnet.login.v0",
|
|
nonce: "AQID",
|
|
auth: "BAUG",
|
|
password: "BwgJ",
|
|
});
|
|
const wire = encodeCultNetMessageForWire(message, "gamecult.networking.v0") as [number, Uint8Array[]];
|
|
assert.equal(wire[0], 0);
|
|
assert.deepEqual([...wire[1][0]], [1, 2, 3]);
|
|
} finally {
|
|
Object.defineProperty(globalThis, "Buffer", { configurable: true, value: originalBuffer });
|
|
}
|
|
});
|
|
|
|
test("database subscription contracts match the C# live document lane", () => {
|
|
const subscribe = parseCultNetMessage({
|
|
schemaVersion: "cultnet.database_subscribe.v0",
|
|
messageId: "subscribe-counter",
|
|
subscriptionId: "counter:browser-1",
|
|
schemaIds: ["sample.counter_state.v1"],
|
|
recordKeys: ["counter:main"],
|
|
includeSnapshot: true,
|
|
consumerRuntimeId: "browser-1",
|
|
});
|
|
assert.equal(subscribe.schemaVersion, "cultnet.database_subscribe.v0");
|
|
assert.equal(subscribe.subscriptionId, "counter:browser-1");
|
|
|
|
const added = parseCultNetMessage({
|
|
schemaVersion: "cultnet.database_change_raw.v0",
|
|
messageId: "change-counter-1",
|
|
subscriptionId: "counter:browser-1",
|
|
changeKind: "added",
|
|
document: {
|
|
schemaId: "sample.counter_state.v1",
|
|
recordKey: "counter:main",
|
|
storedAt: "2026-08-17T00:00:00Z",
|
|
payloadEncoding: "messagepack",
|
|
payload: new Uint8Array([0x81, 0xa5, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x01]),
|
|
},
|
|
});
|
|
assert.equal(added.schemaVersion, "cultnet.database_change_raw.v0");
|
|
assert.equal(added.document?.recordKey, "counter:main");
|
|
|
|
const removed = parseCultNetMessage({
|
|
schemaVersion: "cultnet.database_change_raw.v0",
|
|
messageId: "change-counter-2",
|
|
subscriptionId: "counter:browser-1",
|
|
changeKind: "removed",
|
|
recordKey: "counter:main",
|
|
schemaId: "sample.counter_state.v1",
|
|
});
|
|
assert.equal(removed.schemaVersion, "cultnet.database_change_raw.v0");
|
|
assert.equal(removed.recordKey, "counter:main");
|
|
|
|
const unsubscribe = parseCultNetMessage({
|
|
schemaVersion: "cultnet.database_unsubscribe.v0",
|
|
messageId: "unsubscribe-counter",
|
|
subscriptionId: "counter:browser-1",
|
|
});
|
|
assert.equal(unsubscribe.schemaVersion, "cultnet.database_unsubscribe.v0");
|
|
|
|
assert.throws(
|
|
() => parseCultNetMessage({
|
|
schemaVersion: "cultnet.database_change_raw.v0",
|
|
messageId: "invalid-change",
|
|
subscriptionId: "counter:browser-1",
|
|
changeKind: "updated",
|
|
}),
|
|
/document: is required/,
|
|
);
|
|
});
|
|
|
|
test("RUDP operation service correlates typed payload envelopes", async () => {
|
|
const server = await startCultNetOperationServer({
|
|
runtimeId: "sai-sidecar",
|
|
handler: request => ({
|
|
schemaVersion: "cultnet.operation_response.v0",
|
|
messageId: request.messageId,
|
|
serviceId: request.serviceId,
|
|
operation: request.operation,
|
|
status: "ok",
|
|
payloadSchema: "gamecult.eve.plugin_abi.response.v1",
|
|
payloadEncoding: "messagepack-base64",
|
|
payload: request.payload,
|
|
diagnostics: [],
|
|
sourceRuntimeId: "sai-sidecar",
|
|
}),
|
|
});
|
|
try {
|
|
const response = await invokeCultNetOperation(server.endpoint, {
|
|
schemaVersion: "cultnet.operation_request.v0",
|
|
messageId: "service-1",
|
|
serviceId: "sai.vn",
|
|
operation: "describe",
|
|
payloadSchema: "gamecult.eve.plugin_abi.request.v1",
|
|
payloadEncoding: "messagepack-base64",
|
|
payload: "gaZzY2hlbWHEJ2dhbWVjdWx0LmV2ZS5wbHVnaW5fYWJpLnJlcXVlc3QudjE=",
|
|
}, { runtimeId: "eve-test" });
|
|
assert.equal(response.messageId, "service-1");
|
|
assert.equal(response.serviceId, "sai.vn");
|
|
assert.equal(response.status, "ok");
|
|
} finally {
|
|
await server.close();
|
|
}
|
|
});
|
|
|
|
test("rudp sequence-neutral acknowledgements interoperate with ordered receivers", () => {
|
|
const sender = new CultNetRudpSession({ connectionId: 125, initialSequence: 1 });
|
|
const receiver = new CultNetRudpSession({ connectionId: 125, initialSequence: 100 });
|
|
sender.receive(receiver.acceptConnect(sender.createConnect(0), 0), 0);
|
|
|
|
const ack = sender.createAck();
|
|
const request = sender.send("schema", Buffer.from("snapshot"), { reliable: true, ordered: true });
|
|
|
|
assert.equal(ack.sequence, 0);
|
|
assert.equal(request.sequence, 2);
|
|
assert.deepEqual(receiver.receive(ack).delivered, []);
|
|
assert.deepEqual(receiver.receive(request).delivered.map(frame => Buffer.from(frame.payload).toString("utf8")), ["snapshot"]);
|
|
});
|
|
|
|
// A replicated record whose schema id no binding declares is refused, whatever its payload parses as: the registry never
|
|
// guesses a type from a payload's shape and never relabels a record it does not understand.
|
|
test("CultNet raw replication refuses a record under a schema id no binding declares, whatever its shape", async () => {
|
|
const note = defineDocumentType({
|
|
type: "p5.note", schemaId: "p5.note.v1", schemaName: "p5.note", schema: z.object({ body: z.string() }),
|
|
});
|
|
// Registered first, and accepts every payload: shape routing would store the record under it.
|
|
const anything = defineDocumentType({ type: "p5.anything", schema: z.any() });
|
|
const registry = new CultNetDocumentRegistry([
|
|
defineCultNetDocumentBinding({ definition: anything }),
|
|
defineCultNetDocumentBinding({ definition: note }),
|
|
]);
|
|
const origin = CultCache.builder().withDocumentType(note).build();
|
|
await origin.put(note, "k", { body: "from a peer" });
|
|
const snapshot = registry.createRawSnapshotResponse(origin, "p5");
|
|
assert.equal(snapshot.documents[0]?.schemaId, "p5.note.v1");
|
|
assert.equal(snapshot.documents[0]?.payloadEncoding, "messagepack");
|
|
|
|
for (const schemaId of ["runtime.generated.p5.note.ui.99", "p5.note.v7", "p5.note"]) {
|
|
const target = CultCache.builder().withDocumentType(note).withDocumentType(anything).build();
|
|
await assert.rejects(
|
|
registry.applyRawSnapshotResponse(target, {
|
|
...snapshot,
|
|
documents: snapshot.documents.map(document => ({ ...document, schemaId })),
|
|
}),
|
|
(error: unknown) => error instanceof Error
|
|
&& error.message === `No CultNet document binding is registered for schema "${schemaId}".`,
|
|
);
|
|
assert.deepEqual(target.snapshot(), [], `nothing was stored for ${schemaId}`);
|
|
}
|
|
});
|
|
|
|
// The registry stamps and resolves a definition's schema id exactly as CultCache does, so a record a cache wrote is read
|
|
// back as the type that wrote it on every peer.
|
|
test("CultNet registry derives a definition's schema id as the cache does, and refuses what the cache refuses", async () => {
|
|
const shape = z.object({ body: z.string() });
|
|
const a = defineDocumentType({ type: "p1.a", schemaName: "p1.shared", schema: shape });
|
|
const b = defineDocumentType({ type: "p1.b", schemaId: "p1.shared", schemaName: "p1.b", schema: shape });
|
|
const fillers = [0, 1].map(index => defineCultNetDocumentBinding({
|
|
definition: defineDocumentType({ type: `p1.filler${index}`, schema: shape }),
|
|
}));
|
|
|
|
assert.deepEqual(schemaIdentityOf(a), {
|
|
schemaId: "p1.shared", schemaName: "p1.shared", schemaVersion: "p1.shared.v1", compatibleSchemaIds: ["p1.shared"],
|
|
});
|
|
// The cache refuses B beside A: A's records carry "p1.shared".
|
|
await assert.rejects(CultCache.builder().withDocumentType(a).build().registerDocumentType(b), SchemaConflictError);
|
|
for (const [first, second] of [[a, b], [b, a]]) {
|
|
// Two bindings stand ahead of the holder, so a check that looks only at the first holder misses it.
|
|
const registry = new CultNetDocumentRegistry([...fillers, defineCultNetDocumentBinding({ definition: first })]);
|
|
assert.throws(() => registry.register(defineCultNetDocumentBinding({ definition: second })), (error: unknown) => {
|
|
assert.ok(error instanceof SchemaConflictError, String(error));
|
|
assert.equal(error.schemaId, "p1.shared");
|
|
return true;
|
|
});
|
|
assert.equal(registry.get(second.type), undefined);
|
|
}
|
|
|
|
const registry = new CultNetDocumentRegistry([...fillers, defineCultNetDocumentBinding({ definition: a })]);
|
|
const origin = CultCache.builder().withDocumentType(a).build();
|
|
await origin.put(a, "k", { body: "written by a" });
|
|
const stamped = origin.getRequiredEnvelope(a, "k").schemaId;
|
|
assert.equal(stamped, "p1.shared");
|
|
assert.equal(registry.createDocumentPutMessage(registry.get("p1.a")!, "m", "k", { body: "x" }).document.schemaId, stamped);
|
|
assert.equal(registry.createRawDocumentPutMessage(registry.get("p1.a")!, "m", "k", { body: "x" }).document.schemaId, stamped);
|
|
assert.equal(registry.getBySchemaId(stamped!)?.definition, a);
|
|
|
|
const peer = CultCache.builder().withDocumentType(a).build();
|
|
await registry.applyRawSnapshotResponse(peer, registry.createRawSnapshotResponse(origin, "p1"));
|
|
assert.deepEqual(peer.getRequired(a, "k"), { body: "written by a" });
|
|
assert.equal(peer.getRequiredEnvelope(a, "k").schemaId, stamped);
|
|
});
|
|
|
|
// An id nobody owns resolves to the one binding that lists it as compatible. Several listers and no owner resolve to
|
|
// none, and the refusal does not depend on the order they were registered in. An owner beats every lister.
|
|
test("CultNet registry refuses an unowned schema id that several bindings list, in any order", async () => {
|
|
const shape = z.object({ body: z.string() });
|
|
const first = defineDocumentType({ type: "p2.first", schemaId: "p2.first", compatibleSchemaIds: ["p2.old"], schema: shape });
|
|
const second = defineDocumentType({ type: "p2.second", schemaId: "p2.second", compatibleSchemaIds: ["p2.old"], schema: shape });
|
|
const owner = defineDocumentType({ type: "p2.owner", schemaId: "p2.old", schema: shape });
|
|
const bind = (definition: typeof first) => defineCultNetDocumentBinding({ definition });
|
|
|
|
for (const order of [[first, second], [second, first]]) {
|
|
const registry = new CultNetDocumentRegistry(order.map(bind));
|
|
assert.throws(() => registry.getBySchemaId("p2.old"), (error: unknown) => {
|
|
assert.ok(error instanceof SchemaConflictError, String(error));
|
|
assert.equal(error.schemaId, "p2.old");
|
|
assert.deepEqual(error.schemaNames, ["p2.first", "p2.second"]);
|
|
assert.equal(error.recordKey, "");
|
|
assert.ok(error.message.includes("(p2.first, p2.second)"), error.message);
|
|
assert.ok(error.message.includes("so it resolves to none. Bind the type that owns the id"), error.message);
|
|
return true;
|
|
});
|
|
const cache = CultCache.builder().withDocumentType(first).withDocumentType(second).build();
|
|
const origin = CultCache.builder().withDocumentType(first).build();
|
|
await origin.put(first, "k", { body: "old" });
|
|
const document = { ...registry.createRawSnapshotResponse(origin, "p2").documents[0]!, schemaId: "p2.old" };
|
|
await assert.rejects(
|
|
registry.applyRawDocumentPutMessage(cache, { schemaVersion: "cultnet.document_put_raw.v0", messageId: "p2", document }),
|
|
SchemaConflictError,
|
|
);
|
|
assert.deepEqual(cache.snapshot(), []);
|
|
|
|
for (const withOwner of [[...order, owner], [owner, ...order]]) {
|
|
assert.equal(new CultNetDocumentRegistry(withOwner.map(bind)).getBySchemaId("p2.old")?.definition, owner);
|
|
}
|
|
}
|
|
// One lister, bound twice, is still one lister.
|
|
const single = new CultNetDocumentRegistry([bind(first), bind(first)]);
|
|
assert.equal(single.getBySchemaId("p2.old")?.definition, first);
|
|
// A snapshot filtered by an id a binding lists returns that binding's records under their own id.
|
|
const origin = CultCache.builder().withDocumentType(first).build();
|
|
await origin.put(first, "k", { body: "listed" });
|
|
const filter = { schemaVersion: "cultnet.snapshot_request.v0" as const, messageId: "f", schemaIds: ["p2.old"] };
|
|
assert.deepEqual(single.createRawSnapshotResponse(origin, "f", filter).documents.map(document => document.schemaId), ["p2.first"]);
|
|
assert.deepEqual(single.createSnapshotResponse(origin, "f", filter).documents.map(document => document.schemaId), ["p2.first"]);
|
|
});
|
|
|
|
// A schema id is only ever an id a definition declares. A version string, a schema name, or an id that differs from a
|
|
// declared one by its version suffix names nothing.
|
|
test("CultNet registry resolves only declared schema ids, never a version, a name or an inferred version", async () => {
|
|
const note = defineDocumentType({
|
|
type: "p3.note",
|
|
schemaId: "p3.note.v1",
|
|
schemaName: "p3.note",
|
|
schemaVersion: "p4.note.v3",
|
|
schema: z.object({ body: z.string() }),
|
|
});
|
|
const registry = new CultNetDocumentRegistry([defineCultNetDocumentBinding({ definition: note })]);
|
|
const cache = CultCache.builder().withDocumentType(note).build();
|
|
await cache.put(note, "k", { body: "held" });
|
|
|
|
assert.equal(registry.getBySchemaId("p3.note.v1")?.definition, note);
|
|
for (const undeclared of ["p3.note.v7", "p4.note.v3", "p3.note"]) {
|
|
assert.equal(registry.getBySchemaId(undeclared), undefined, undeclared);
|
|
const filter = { schemaVersion: "cultnet.snapshot_request.v0" as const, messageId: "f", schemaIds: [undeclared] };
|
|
assert.deepEqual(registry.createSnapshotResponse(cache, "f", filter).documents, [], undeclared);
|
|
assert.deepEqual(registry.createRawSnapshotResponse(cache, "f", filter).documents, [], undeclared);
|
|
await assert.rejects(registry.applyDocumentPutMessage(cache, {
|
|
schemaVersion: "cultnet.document_put.v0",
|
|
messageId: "put",
|
|
document: { schemaId: undeclared, recordKey: "k", storedAt: "2026-09-30T00:00:00.000Z", payload: { body: "relabelled" } },
|
|
}), /No CultNet document binding/);
|
|
await assert.rejects(registry.applyDocumentDeleteMessage(cache, {
|
|
schemaVersion: "cultnet.document_delete.v0", messageId: "delete", schemaId: undeclared, recordKey: "k",
|
|
}), /No CultNet document binding/);
|
|
assert.deepEqual(cache.getRequired(note, "k"), { body: "held" });
|
|
}
|
|
const filter = { schemaVersion: "cultnet.snapshot_request.v0" as const, messageId: "f", schemaIds: ["p3.note.v1"] };
|
|
assert.equal(registry.createSnapshotResponse(cache, "f", filter).documents.length, 1);
|
|
assert.equal(registry.createRawSnapshotResponse(cache, "f", filter).documents.length, 1);
|
|
});
|
|
|
|
test("CultNet document registry binds one type per schema and refuses a second, typed", () => {
|
|
const noteShape = z.object({ noteId: z.string() });
|
|
const owner = defineDocumentType({ type: "tests.note", schemaId: "id.note", schemaName: "tests.note", schema: noteShape });
|
|
const sameId = defineDocumentType({ type: "tests.note-ui", schemaId: "id.note", schemaName: "tests.note-ui", schema: noteShape });
|
|
const sameName = defineDocumentType({ type: "tests.note-named", schemaId: "id.other", schemaName: "tests.note", schema: noteShape });
|
|
const sameType = defineDocumentType({ type: "tests.note", schemaId: "id.third", schemaName: "tests.third", schema: noteShape });
|
|
const lister = defineDocumentType({
|
|
type: "tests.note-lister", schemaId: "id.lister", schemaName: "tests.note-lister", compatibleSchemaIds: ["id.note"], schema: noteShape,
|
|
});
|
|
|
|
for (const [claimant, schemaId, names] of [
|
|
[sameId, "id.note", ["tests.note", "tests.note-ui"]],
|
|
[sameName, "id.other", ["tests.note", "tests.note"]],
|
|
[sameType, "id.third", ["tests.note", "tests.third"]],
|
|
] as const) {
|
|
for (const order of [[owner, claimant], [claimant, owner]]) {
|
|
// Two bindings are registered ahead of the holder, so a conflict loop that checks only the first holder fails.
|
|
const registry = new CultNetDocumentRegistry([
|
|
...["tests.filler-a", "tests.filler-b"].map(type => defineCultNetDocumentBinding({
|
|
definition: defineDocumentType({ type, schema: noteShape }),
|
|
})),
|
|
defineCultNetDocumentBinding({ definition: order[0] }),
|
|
]);
|
|
assert.throws(() => registry.register(defineCultNetDocumentBinding({ definition: order[1] })), (error: unknown) => {
|
|
assert.ok(error instanceof SchemaConflictError, String(error));
|
|
const ownerFirst = order[0] === owner;
|
|
assert.equal(error.schemaId, ownerFirst ? schemaId : "id.note");
|
|
assert.deepEqual(error.schemaNames, ownerFirst ? names : [...names].reverse());
|
|
assert.equal(error.recordKey, "");
|
|
assert.ok(error.message.includes(`schema id "${error.schemaId}"`), error.message);
|
|
return true;
|
|
});
|
|
// The refused binding left nothing behind: the schema still resolves to the first type.
|
|
assert.equal(registry.getBySchemaId(order[0].schemaId!)?.definition, order[0]);
|
|
assert.equal(registry.get(order[1].type)?.definition, order[1].type === order[0].type ? order[0] : undefined);
|
|
}
|
|
}
|
|
|
|
// The same definition may be bound again, and a definition listing the id as compatible is not a second owner.
|
|
const registry = new CultNetDocumentRegistry([defineCultNetDocumentBinding({ definition: owner })]);
|
|
registry.register(defineCultNetDocumentBinding({ definition: owner, payloadSchemaVersion: "tests.note.v2" }));
|
|
registry.register(defineCultNetDocumentBinding({ definition: lister }));
|
|
assert.equal(registry.get("tests.note")?.payloadSchemaVersion, "tests.note.v2");
|
|
assert.equal(registry.getBySchemaId("id.note")?.definition, owner);
|
|
assert.equal(registry.getBySchemaId("id.lister")?.definition, lister);
|
|
});
|
|
|
|
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 });
|
|
}
|
|
});
|
|
|
|
test("rudp receiver evicts stranded fragment sets instead of refusing new ones", () => {
|
|
// A fragment set is removed only on successful reassembly, so every set that
|
|
// loses a fragment is stranded for the life of the session. Ghostlight sends
|
|
// Heimdall fragmented envelopes (max_fragment_bytes = 2048), so on the live
|
|
// auth path this map grew for every lost fragment and was never swept.
|
|
const receiver = new CultNetRudpSession({ connectionId: 9, initialSequence: 1, resendDelayMs: 50 });
|
|
receiver.acceptConnect(new CultNetRudpSession({ connectionId: 9, initialSequence: 1 }).createConnect(0), 0);
|
|
|
|
// 200 fragment sets that each lose their second fragment.
|
|
for (let id = 1; id <= 200; id += 1) {
|
|
const stranded = receiver.receive({
|
|
packetType: "data",
|
|
connectionId: 9,
|
|
sequence: 100 + id,
|
|
ack: 0,
|
|
ackMask: 0,
|
|
channelId: "schema",
|
|
fragmentId: id,
|
|
fragmentIndex: 0,
|
|
fragmentCount: 2,
|
|
payload: Buffer.from([id & 0xff]),
|
|
});
|
|
assert.equal(stranded.delivered.length, 0, "an incomplete set must not reassemble");
|
|
}
|
|
|
|
// Bounded rather than unbounded, and still serving.
|
|
assert.ok(receiver.pendingFragmentSetCount <= 64, `pending ${receiver.pendingFragmentSetCount}`);
|
|
assert.ok(receiver.fragmentSetsEvicted > 0, "nothing was evicted");
|
|
|
|
// The receiver still completes a fresh fragmented payload, which is what
|
|
// erroring at the cap took away: any nonzero loss killed the channel.
|
|
const first = receiver.receive({
|
|
packetType: "data", connectionId: 9, sequence: 900, ack: 0, ackMask: 0,
|
|
channelId: "schema", fragmentId: 1000, fragmentIndex: 0, fragmentCount: 2,
|
|
payload: Buffer.from("he"),
|
|
});
|
|
assert.equal(first.delivered.length, 0);
|
|
const second = receiver.receive({
|
|
packetType: "data", connectionId: 9, sequence: 901, ack: 0, ackMask: 0,
|
|
channelId: "schema", fragmentId: 1000, fragmentIndex: 1, fragmentCount: 2,
|
|
payload: Buffer.from("llo"),
|
|
});
|
|
assert.equal(second.delivered.length, 1);
|
|
assert.equal(Buffer.from(second.delivered[0]!.payload).toString(), "hello");
|
|
});
|
|
|
|
test("rudp socket transport drops strays without an error event and keeps serving its peer", async () => {
|
|
const serverSocket = await bindUdpSocket();
|
|
const clientSocket = await bindUdpSocket();
|
|
const strayHost = await bindUdpSocket();
|
|
const connectionId = 0x10203050;
|
|
const server = new CultNetRudpSocketTransportConnection({
|
|
runtimeId: "rudp-server",
|
|
socket: serverSocket,
|
|
mode: "server",
|
|
connectionId,
|
|
resendPollMs: 5,
|
|
});
|
|
const client = new CultNetRudpSocketTransportConnection({
|
|
runtimeId: "rudp-client",
|
|
socket: clientSocket,
|
|
mode: "client",
|
|
remoteHost: "127.0.0.1",
|
|
remotePort: udpPort(serverSocket),
|
|
connectionId,
|
|
resendPollMs: 5,
|
|
});
|
|
|
|
try {
|
|
// No "error" listener on either side: an unhandled "error" event throws
|
|
// and would end the process, which is what a stray must never do.
|
|
const frames: string[] = [];
|
|
server.on("frame", (frame) => frames.push(Buffer.from(frame.payload).toString("utf8")));
|
|
client.connect();
|
|
await waitFor(() => client.connected && server.connected, "RUDP socket handshake");
|
|
|
|
const foreign = new CultNetRudpSession({ connectionId: 0x0badf10e });
|
|
const strays = [
|
|
Buffer.from("not a rudp packet"),
|
|
Buffer.alloc(0),
|
|
Buffer.from(encodeRudpPacket(foreign.createConnect(0, Buffer.from("foreign")))),
|
|
];
|
|
for (const stray of strays) {
|
|
// From an unrelated address, and from the peer's own address.
|
|
strayHost.send(stray, udpPort(serverSocket), "127.0.0.1");
|
|
clientSocket.send(stray, udpPort(serverSocket), "127.0.0.1");
|
|
}
|
|
await waitFor(() => server.stats.packetsDropped >= strays.length * 2, "strays counted");
|
|
|
|
client.send("schema", Buffer.from("still here", "utf8"));
|
|
await waitFor(() => frames.length === 1, "the real frame");
|
|
assert.deepEqual(frames, ["still here"]);
|
|
|
|
// The client side too: a moved flow answers from the server address.
|
|
for (const stray of strays) {
|
|
serverSocket.send(stray, udpPort(clientSocket), "127.0.0.1");
|
|
}
|
|
await waitFor(() => client.stats.packetsDropped >= strays.length, "client strays counted");
|
|
const clientFrames: string[] = [];
|
|
client.on("frame", (frame) => clientFrames.push(Buffer.from(frame.payload).toString("utf8")));
|
|
server.send("schema", Buffer.from("server still here", "utf8"));
|
|
await waitFor(() => clientFrames.length === 1, "the real server frame");
|
|
} finally {
|
|
strayHost.close();
|
|
client.close();
|
|
server.close();
|
|
}
|
|
});
|
|
|
|
/** A reliable frame no session accepts: a fragment set with no fragment id. */
|
|
function poisonedFrame(session: CultNetRudpSession): CultNetRudpPacket {
|
|
const [packet] = session.sendMany("schema", Buffer.from("poison"), { reliable: true, ordered: true, nowMs: 0 });
|
|
return { ...packet!, fragmentCount: 2, fragmentId: 0, fragmentIndex: 0 };
|
|
}
|
|
|
|
test("rudp socket transport ends the session that refuses a reliable frame and never acknowledges it", async () => {
|
|
const serverSocket = await bindUdpSocket();
|
|
const peerSocket = await bindUdpSocket();
|
|
const connectionId = 0x10203051;
|
|
const server = new CultNetRudpSocketTransportConnection({
|
|
runtimeId: "rudp-server",
|
|
socket: serverSocket,
|
|
mode: "server",
|
|
connectionId,
|
|
resendPollMs: 1000,
|
|
});
|
|
const received: CultNetRudpPacket[] = [];
|
|
peerSocket.on("message", (wire) => received.push(decodeRudpPacket(wire)));
|
|
const peer = new CultNetRudpSession({ connectionId });
|
|
const toServer = (packet: CultNetRudpPacket) =>
|
|
peerSocket.send(encodeRudpPacket(packet), udpPort(serverSocket), "127.0.0.1");
|
|
const disconnects: string[] = [];
|
|
server.on("disconnect", ({ reason }) => disconnects.push(Buffer.from(reason).toString("utf8")));
|
|
|
|
try {
|
|
toServer(peer.createConnect(0));
|
|
await waitFor(() => received.some((p) => p.packetType === "accept"), "the Accept");
|
|
peer.receive(received.find((p) => p.packetType === "accept")!, 0);
|
|
|
|
const poison = poisonedFrame(peer);
|
|
toServer(poison);
|
|
await waitFor(() => disconnects.length === 1, "the server ending the session");
|
|
await waitFor(() => received.some((p) => p.packetType === "disconnect"), "the goodbye");
|
|
assert.equal(server.stats.packetsDropped, 1);
|
|
assert.equal(server.connected, false);
|
|
|
|
// The peer sees the session end and never sees the frame acknowledged,
|
|
// neither in the goodbye nor after it retransmits.
|
|
const goodbye = received.find((p) => p.packetType === "disconnect")!;
|
|
assert.equal(peer.receive(goodbye, 1).disconnected, true);
|
|
assert.equal(acknowledges(goodbye, poison.sequence), false, "the goodbye acknowledged the refused frame");
|
|
const before = received.length;
|
|
toServer(poison);
|
|
await waitFor(() => server.stats.packetsDropped === 2, "the retransmit dropped");
|
|
await new Promise((resolve) => setTimeout(resolve, 30));
|
|
assert.equal(received.length, before, "the server must not answer a retransmit for an ended session");
|
|
|
|
// Only a Connect claims the endpoint again.
|
|
const again = new CultNetRudpSession({ connectionId });
|
|
toServer(again.createConnect(0));
|
|
await waitFor(() => received.length > before, "the new Accept");
|
|
assert.ok(received.slice(before).some((p) => p.packetType === "accept"));
|
|
} finally {
|
|
peerSocket.close();
|
|
server.close();
|
|
}
|
|
});
|
|
|
|
test("rudp sessions reject sequence spaces and queues that cannot admit a Connect", () => {
|
|
assert.throws(() => new CultNetRudpSession({ connectionId: 1, initialSequence: 0xffff_ffff }), /initialSequence/);
|
|
assert.throws(() => new CultNetRudpSession({ connectionId: 1, maxPendingReliablePackets: 0 }), /greater than zero/);
|
|
assert.doesNotThrow(() => new CultNetRudpSession({ connectionId: 1, initialSequence: 0xffff_fffe, maxPendingReliablePackets: 1 }));
|
|
});
|
|
|
|
test("operation service drops and counts a packet its session refuses and keeps serving", async () => {
|
|
const handler = (request: CultNetOperationRequestMessage): CultNetOperationResponseMessage => ({
|
|
schemaVersion: "cultnet.operation_response.v0",
|
|
messageId: request.messageId,
|
|
serviceId: request.serviceId,
|
|
operation: request.operation,
|
|
status: "ok",
|
|
payloadSchema: "gamecult.eve.plugin_abi.response.v1",
|
|
payloadEncoding: "messagepack-base64",
|
|
payload: request.payload,
|
|
diagnostics: [],
|
|
sourceRuntimeId: "sai-sidecar",
|
|
});
|
|
const server = await startCultNetOperationServer({ runtimeId: "sai-sidecar", handler });
|
|
const raw = await bindUdpSocket();
|
|
const port = Number(new URL(server.endpoint).port);
|
|
const connectionId = 0x43554c54;
|
|
const received: CultNetRudpPacket[] = [];
|
|
raw.on("message", (wire) => received.push(decodeRudpPacket(wire)));
|
|
const peer = new CultNetRudpSession({ connectionId });
|
|
const toServer = (packet: CultNetRudpPacket) => raw.send(encodeRudpPacket(packet), port, "127.0.0.1");
|
|
try {
|
|
toServer(peer.createConnect(0));
|
|
await waitFor(() => received.some((p) => p.packetType === "accept"), "the Accept");
|
|
peer.receive(received[0]!, 0);
|
|
toServer(poisonedFrame(peer));
|
|
await waitFor(() => server.packetsDropped === 1, "the poisoned frame dropped");
|
|
await waitFor(() => received.some((p) => p.packetType === "disconnect"), "the session ended");
|
|
|
|
// A frame the session accepts but the service cannot handle rejects inside
|
|
// the handler; that too is a dropped packet, not a dead process.
|
|
const garbage = new CultNetRudpSession({ connectionId });
|
|
received.length = 0;
|
|
toServer(garbage.createConnect(0));
|
|
await waitFor(() => received.some((p) => p.packetType === "accept"), "the second Accept");
|
|
garbage.receive(received[0]!, 0);
|
|
const [notMessagePack] = garbage.sendMany("schema", Buffer.from([0xc1]), { reliable: true, ordered: true, nowMs: 0 });
|
|
toServer(notMessagePack!);
|
|
await waitFor(() => server.packetsDropped === 2, "the unhandled frame dropped");
|
|
// The session that could not serve it ends and the client is told, so its
|
|
// retransmit is not acknowledged for a request that was never handled.
|
|
await waitFor(() => received.some((p) => p.packetType === "disconnect"), "the goodbye");
|
|
const beforeRetransmit = received.length;
|
|
toServer(notMessagePack!);
|
|
await waitFor(() => server.packetsDropped === 3, "the retransmit dropped");
|
|
await new Promise((resolve) => setTimeout(resolve, 30));
|
|
assert.equal(received.length, beforeRetransmit, "the retransmit of an unhandled request was answered");
|
|
|
|
const response = await invokeCultNetOperation(server.endpoint, {
|
|
schemaVersion: "cultnet.operation_request.v0",
|
|
messageId: "after-poison",
|
|
serviceId: "sai.vn",
|
|
operation: "describe",
|
|
payloadSchema: "gamecult.eve.plugin_abi.request.v1",
|
|
payloadEncoding: "messagepack-base64",
|
|
payload: "gaZzY2hlbWE=",
|
|
}, { runtimeId: "eve-test" });
|
|
assert.equal(response.messageId, "after-poison");
|
|
} finally {
|
|
raw.close();
|
|
await server.close();
|
|
}
|
|
});
|
|
|
|
test("operation service drops a Connect once its session table is full", async () => {
|
|
const server = await startCultNetOperationServer({
|
|
runtimeId: "sai-sidecar",
|
|
handler: () => { throw new Error("no requests expected"); },
|
|
});
|
|
const port = Number(new URL(server.endpoint).port);
|
|
const sockets: Socket[] = [];
|
|
try {
|
|
for (let index = 0; index < 66; index += 1) {
|
|
const socket = await bindUdpSocket();
|
|
sockets.push(socket);
|
|
socket.send(encodeRudpPacket(new CultNetRudpSession({ connectionId: 0x43554c54 }).createConnect(0)), port, "127.0.0.1");
|
|
}
|
|
await waitFor(() => server.packetsDropped === 2, "the Connects past the cap dropped");
|
|
await new Promise((resolve) => setTimeout(resolve, 30));
|
|
assert.equal(server.packetsDropped, 2);
|
|
} finally {
|
|
for (const socket of sockets) socket.close();
|
|
await server.close();
|
|
}
|
|
});
|
|
|
|
test("rudp flush fails for a write the ended session forgot", async () => {
|
|
const serverSocket = await bindUdpSocket();
|
|
const peerSocket = await bindUdpSocket();
|
|
const connectionId = 0x10203056;
|
|
const server = new CultNetRudpSocketTransportConnection({
|
|
runtimeId: "rudp-server",
|
|
socket: serverSocket,
|
|
mode: "server",
|
|
connectionId,
|
|
resendPollMs: 1000,
|
|
});
|
|
const received: CultNetRudpPacket[] = [];
|
|
peerSocket.on("message", (wire) => received.push(decodeRudpPacket(wire)));
|
|
const peer = new CultNetRudpSession({ connectionId });
|
|
const toServer = (packet: CultNetRudpPacket) =>
|
|
peerSocket.send(encodeRudpPacket(packet), udpPort(serverSocket), "127.0.0.1");
|
|
try {
|
|
toServer(peer.createConnect(0));
|
|
await waitFor(() => received.some((p) => p.packetType === "accept"), "the Accept");
|
|
peer.receive(received.find((p) => p.packetType === "accept")!, 0);
|
|
server.send("schema", Buffer.from("never acknowledged"));
|
|
toServer(poisonedFrame(peer));
|
|
await waitFor(() => server.stats.packetsDropped === 1, "the refusal");
|
|
await assert.rejects(server.flush(200), /ended before its reliable writes were acknowledged/);
|
|
} finally {
|
|
peerSocket.close();
|
|
server.close();
|
|
}
|
|
});
|
|
|
|
test("a Connect that repeats after its Accept was acknowledged is answered with an Ack and queues nothing", () => {
|
|
const connectionId = 0x10203057;
|
|
const client = new CultNetRudpSession({ connectionId });
|
|
const server = new CultNetRudpSession({ connectionId });
|
|
const connect = client.createConnect(0);
|
|
const accept = server.acceptConnect(connect, 10);
|
|
client.receive(accept, 11);
|
|
server.receive(client.createAckForReceived(accept.sequence), 12);
|
|
assert.equal(server.outstandingReliablePacketCount, 0);
|
|
for (let attempt = 0; attempt < 20; attempt += 1) {
|
|
assert.equal(server.acceptConnect(connect, 20 + attempt).packetType, "ack");
|
|
}
|
|
assert.equal(server.outstandingReliablePacketCount, 0);
|
|
});
|
|
|
|
test("server-mode transport and operation service owe one reliable Accept however many Connects repeat", async () => {
|
|
const connectionId = 0x43554c54;
|
|
const serverSocket = await bindUdpSocket();
|
|
const transport = new CultNetRudpSocketTransportConnection({
|
|
runtimeId: "rudp-server",
|
|
socket: serverSocket,
|
|
mode: "server",
|
|
connectionId,
|
|
resendPollMs: 1000,
|
|
});
|
|
const service = await startCultNetOperationServer({
|
|
runtimeId: "sai-sidecar",
|
|
handler: () => { throw new Error("no requests expected"); },
|
|
});
|
|
const raw = await bindUdpSocket();
|
|
let received: CultNetRudpPacket[] = [];
|
|
raw.on("message", (wire) => received.push(decodeRudpPacket(wire)));
|
|
const settle = () => new Promise((resolve) => setTimeout(resolve, 100));
|
|
try {
|
|
for (const port of [udpPort(serverSocket), Number(new URL(service.endpoint).port)]) {
|
|
const peer = new CultNetRudpSession({ connectionId });
|
|
const connect = peer.createConnect(0);
|
|
received = [];
|
|
for (let attempt = 0; attempt < 20; attempt += 1) raw.send(encodeRudpPacket(connect), port, "127.0.0.1");
|
|
await settle();
|
|
const accepts = received.filter((p) => p.packetType === "accept");
|
|
assert.equal(new Set(accepts.map((p) => p.sequence)).size, 1, `reliable Accepts sent by port ${port}`);
|
|
|
|
// Once the Accept is acknowledged, a repeated Connect is answered with an Ack, never a new Accept.
|
|
peer.receive(accepts[0]!, 0);
|
|
raw.send(encodeRudpPacket(peer.createAckForReceived(accepts[0]!.sequence)), port, "127.0.0.1");
|
|
await settle();
|
|
received = [];
|
|
for (let attempt = 0; attempt < 20; attempt += 1) raw.send(encodeRudpPacket(connect), port, "127.0.0.1");
|
|
await settle();
|
|
assert.equal(received.filter((p) => p.packetType === "accept").length, 0, `Accepts sent by port ${port} after the ack`);
|
|
assert.ok(received.some((p) => p.packetType === "ack"), `an Ack from port ${port}`);
|
|
}
|
|
assert.equal(transport.outstandingReliablePacketCount, 0);
|
|
} finally {
|
|
raw.close();
|
|
transport.close();
|
|
await service.close();
|
|
}
|
|
});
|
|
|
|
test("operation service frees the slots of abandoned sessions once they idle out", async () => {
|
|
const server = await startCultNetOperationServer({
|
|
runtimeId: "sai-sidecar",
|
|
sessionIdleTimeoutMs: 300,
|
|
handler: (request): CultNetOperationResponseMessage => ({
|
|
schemaVersion: "cultnet.operation_response.v0",
|
|
messageId: request.messageId,
|
|
serviceId: request.serviceId,
|
|
operation: request.operation,
|
|
status: "ok",
|
|
payloadSchema: "gamecult.eve.plugin_abi.response.v1",
|
|
payloadEncoding: "messagepack-base64",
|
|
payload: request.payload,
|
|
diagnostics: [],
|
|
sourceRuntimeId: "sai-sidecar",
|
|
}),
|
|
});
|
|
const port = Number(new URL(server.endpoint).port);
|
|
const sockets: Socket[] = [];
|
|
const request: CultNetOperationRequestMessage = {
|
|
schemaVersion: "cultnet.operation_request.v0",
|
|
messageId: "after-abandonment",
|
|
serviceId: "sai.vn",
|
|
operation: "describe",
|
|
payloadSchema: "gamecult.eve.plugin_abi.request.v1",
|
|
payloadEncoding: "messagepack-base64",
|
|
payload: "gaZzY2hlbWE=",
|
|
};
|
|
try {
|
|
for (let index = 0; index < 64; index += 1) {
|
|
const socket = await bindUdpSocket();
|
|
sockets.push(socket);
|
|
socket.send(encodeRudpPacket(new CultNetRudpSession({ connectionId: 0x43554c54 }).createConnect(0)), port, "127.0.0.1");
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 60));
|
|
await assert.rejects(invokeCultNetOperation(server.endpoint, request, { runtimeId: "eve-test", timeoutMs: 100 }));
|
|
await new Promise((resolve) => setTimeout(resolve, 400));
|
|
const response = await invokeCultNetOperation(server.endpoint, request, { runtimeId: "eve-test" });
|
|
assert.equal(response.messageId, "after-abandonment");
|
|
} finally {
|
|
for (const socket of sockets) socket.close();
|
|
await server.close();
|
|
}
|
|
});
|
|
|
|
test("an RUDP client binds loopback only for a loopback endpoint", async () => {
|
|
assert.equal(await rudpClientBindHost("10.77.0.1"), "0.0.0.0");
|
|
assert.equal(await rudpClientBindHost("2001:db8::1"), "::");
|
|
assert.equal(await rudpClientBindHost("[2001:db8::1]"), "::");
|
|
assert.equal(await rudpClientBindHost("127.0.0.1"), "127.0.0.1");
|
|
assert.equal(await rudpClientBindHost("127.0.0.2"), "127.0.0.1");
|
|
assert.equal(await rudpClientBindHost("::1"), "::1");
|
|
assert.equal(await rudpClientBindHost("[::1]"), "::1");
|
|
});
|
|
|
|
// A socket bound to loopback cannot send off the host on Windows. On Linux it
|
|
// reaches a local non-loopback address, but from 127.0.0.1, so the source
|
|
// address shows which one the client bound.
|
|
test("an operation client for a non-loopback endpoint does not send from loopback", async (t) => {
|
|
const address = Object.values(networkInterfaces())
|
|
.flat()
|
|
.find((entry) => entry && entry.family === "IPv4" && !entry.internal)?.address;
|
|
if (!address) {
|
|
t.skip("no non-loopback IPv4 interface on this host");
|
|
return;
|
|
}
|
|
const listener = dgram.createSocket("udp4");
|
|
await new Promise<void>((resolve) => listener.bind(0, "0.0.0.0", resolve));
|
|
const source = new Promise<string>((resolve) => listener.once("message", (_wire, remote) => resolve(remote.address)));
|
|
const request: CultNetOperationRequestMessage = {
|
|
schemaVersion: "cultnet.operation_request.v0",
|
|
messageId: "bind-host",
|
|
serviceId: "sai.vn",
|
|
operation: "describe",
|
|
payloadSchema: "gamecult.eve.plugin_abi.request.v1",
|
|
payloadEncoding: "messagepack-base64",
|
|
payload: "gaZzY2hlbWE=",
|
|
};
|
|
try {
|
|
await assert.rejects(invokeCultNetOperation(`rudp://${address}:${udpPort(listener)}`, request, { runtimeId: "bind-test", timeoutMs: 300 }));
|
|
assert.equal(await source, address);
|
|
} finally {
|
|
listener.close();
|
|
}
|
|
});
|
|
|
|
test("server-mode transport replaces its peer when a Connect arrives from another endpoint", async () => {
|
|
const serverSocket = await bindUdpSocket();
|
|
const socketA = await bindUdpSocket();
|
|
const socketB = await bindUdpSocket();
|
|
const connectionId = 0x10203058;
|
|
const server = new CultNetRudpSocketTransportConnection({
|
|
runtimeId: "rudp-server",
|
|
socket: serverSocket,
|
|
mode: "server",
|
|
connectionId,
|
|
resendPollMs: 1000,
|
|
});
|
|
const receivedByA: CultNetRudpPacket[] = [];
|
|
socketA.on("message", (wire) => receivedByA.push(decodeRudpPacket(wire)));
|
|
const receivedByB: CultNetRudpPacket[] = [];
|
|
socketB.on("message", (wire) => receivedByB.push(decodeRudpPacket(wire)));
|
|
const frames: string[] = [];
|
|
server.on("frame", (frame: { payload: Uint8Array }) => frames.push(Buffer.from(frame.payload).toString("utf8")));
|
|
const peerA = new CultNetRudpSession({ connectionId });
|
|
const peerB = new CultNetRudpSession({ connectionId });
|
|
const send = (socket: Socket, packet: CultNetRudpPacket) =>
|
|
socket.send(encodeRudpPacket(packet), udpPort(serverSocket), "127.0.0.1");
|
|
try {
|
|
send(socketA, peerA.createConnect(0));
|
|
await waitFor(() => receivedByA.some((p) => p.packetType === "accept"), "A's Accept");
|
|
const acceptA = receivedByA.find((p) => p.packetType === "accept")!;
|
|
peerA.receive(acceptA, 0);
|
|
send(socketA, peerA.createAckForReceived(acceptA.sequence));
|
|
await waitFor(() => server.outstandingReliablePacketCount === 0, "A's Accept acknowledged");
|
|
send(socketB, peerB.createConnect(0));
|
|
await waitFor(() => receivedByB.some((p) => p.packetType === "accept"), "B's Accept");
|
|
peerB.receive(receivedByB.find((p) => p.packetType === "accept")!, 0);
|
|
|
|
// B owns the session; A's traffic is a stranger's and is dropped.
|
|
const [fromA] = peerA.sendMany("schema", Buffer.from("from-A"), { reliable: true, ordered: true, nowMs: 0 });
|
|
send(socketA, fromA!);
|
|
const [fromB] = peerB.sendMany("schema", Buffer.from("from-B"), { reliable: true, ordered: true, nowMs: 0 });
|
|
send(socketB, fromB!);
|
|
await waitFor(() => frames.length > 0, "B's frame");
|
|
await new Promise((resolve) => setTimeout(resolve, 30));
|
|
assert.deepEqual(frames, ["from-B"]);
|
|
assert.equal(server.stats.packetsDropped, 1);
|
|
} finally {
|
|
socketA.close();
|
|
socketB.close();
|
|
assert.doesNotThrow(() => server.close());
|
|
}
|
|
});
|
|
|
|
test("operation service does not idle out a session while its handler is running", async () => {
|
|
const respond = (request: CultNetOperationRequestMessage): CultNetOperationResponseMessage => ({
|
|
schemaVersion: "cultnet.operation_response.v0",
|
|
messageId: request.messageId,
|
|
serviceId: request.serviceId,
|
|
operation: request.operation,
|
|
status: "ok",
|
|
payloadSchema: "gamecult.eve.plugin_abi.response.v1",
|
|
payloadEncoding: "messagepack-base64",
|
|
payload: request.payload,
|
|
diagnostics: [],
|
|
sourceRuntimeId: "sai-sidecar",
|
|
});
|
|
const server = await startCultNetOperationServer({
|
|
runtimeId: "sai-sidecar",
|
|
sessionIdleTimeoutMs: 200,
|
|
handler: async (request) => {
|
|
await new Promise((resolve) => setTimeout(resolve, 700));
|
|
return respond(request);
|
|
},
|
|
});
|
|
try {
|
|
const response = await invokeCultNetOperation(server.endpoint, {
|
|
schemaVersion: "cultnet.operation_request.v0",
|
|
messageId: "slow",
|
|
serviceId: "sai.vn",
|
|
operation: "describe",
|
|
payloadSchema: "gamecult.eve.plugin_abi.request.v1",
|
|
payloadEncoding: "messagepack-base64",
|
|
payload: "gaZzY2hlbWE=",
|
|
}, { runtimeId: "eve-test", timeoutMs: 3000 });
|
|
assert.equal(response.messageId, "slow");
|
|
} finally {
|
|
await server.close();
|
|
}
|
|
});
|
|
|
|
test("a flush is bound to the session it started in, even when a disconnect listener reconnects", async () => {
|
|
const peerSocket = await bindUdpSocket();
|
|
const clientSocket = await bindUdpSocket();
|
|
const connectionId = 0x10203059;
|
|
const client = new CultNetRudpSocketTransportConnection({
|
|
runtimeId: "rudp-client",
|
|
socket: clientSocket,
|
|
mode: "client",
|
|
remoteHost: "127.0.0.1",
|
|
remotePort: udpPort(peerSocket),
|
|
connectionId,
|
|
resendDelayMs: 1000,
|
|
resendPollMs: 1000,
|
|
});
|
|
const received: CultNetRudpPacket[] = [];
|
|
let clientPort = 0;
|
|
peerSocket.on("message", (wire, remote) => {
|
|
clientPort = remote.port;
|
|
received.push(decodeRudpPacket(wire));
|
|
});
|
|
const peer = new CultNetRudpSession({ connectionId });
|
|
try {
|
|
client.connect();
|
|
await waitFor(() => received.some((p) => p.packetType === "connect"), "the Connect");
|
|
const accept = peer.acceptConnect(received.find((p) => p.packetType === "connect")!, 0);
|
|
peerSocket.send(encodeRudpPacket(accept), clientPort, "127.0.0.1");
|
|
await waitFor(() => client.connected, "the client connected");
|
|
client.send("schema", Buffer.from("never acknowledged"));
|
|
client.on("disconnect", () => client.connect());
|
|
const flushed = client.flush(500);
|
|
const outcome = assert.rejects(flushed, /ended before its reliable writes were acknowledged/);
|
|
peerSocket.send(encodeRudpPacket(peer.createDisconnect(Buffer.from("bye"))), clientPort, "127.0.0.1");
|
|
await outcome;
|
|
} finally {
|
|
peerSocket.close();
|
|
client.close();
|
|
}
|
|
});
|
|
|
|
test("a repeated Connect acknowledges what it carries", () => {
|
|
const connectionId = 0x1020305a;
|
|
const client = new CultNetRudpSession({ connectionId });
|
|
const server = new CultNetRudpSession({ connectionId });
|
|
const connect = client.createConnect(0);
|
|
const accept = server.acceptConnect(connect, 0);
|
|
const reply = server.acceptConnect({ ...connect, ack: accept.sequence }, 1);
|
|
assert.equal(reply.packetType, "ack");
|
|
assert.equal(server.outstandingReliablePacketCount, 0);
|
|
});
|
|
|
|
async function timedOutServerWithUnackedWrite(connectionId: number) {
|
|
const serverSocket = await bindUdpSocket();
|
|
const socketA = await bindUdpSocket();
|
|
const socketB = await bindUdpSocket();
|
|
const server = new CultNetRudpSocketTransportConnection({
|
|
runtimeId: "rudp-server",
|
|
socket: serverSocket,
|
|
mode: "server",
|
|
connectionId,
|
|
resendDelayMs: 1000,
|
|
resendPollMs: 1000,
|
|
});
|
|
const receivedByA: CultNetRudpPacket[] = [];
|
|
socketA.on("message", (wire) => receivedByA.push(decodeRudpPacket(wire)));
|
|
const peerA = new CultNetRudpSession({ connectionId });
|
|
const send = (socket: Socket, packet: CultNetRudpPacket) =>
|
|
socket.send(encodeRudpPacket(packet), udpPort(serverSocket), "127.0.0.1");
|
|
send(socketA, peerA.createConnect(0));
|
|
await waitFor(() => receivedByA.some((p) => p.packetType === "accept"), "A's Accept");
|
|
const acceptA = receivedByA.find((p) => p.packetType === "accept")!;
|
|
peerA.receive(acceptA, 0);
|
|
send(socketA, peerA.createAckForReceived(acceptA.sequence));
|
|
await waitFor(() => server.outstandingReliablePacketCount === 0, "A's Accept acknowledged");
|
|
server.send("schema", Buffer.from("never acknowledged"));
|
|
assert.ok(server.outstandingReliablePacketCount > 0);
|
|
const flushed = server.flush(2000);
|
|
const outcome = assert.rejects(flushed, /ended before its reliable writes were acknowledged/);
|
|
// The flush must have started before the timeout fires.
|
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
assert.equal(server.checkTimeout(50, Date.now() + 10_000), true);
|
|
return {
|
|
server, socketA, socketB, outcome, send,
|
|
close: () => {
|
|
socketA.close();
|
|
socketB.close();
|
|
server.close();
|
|
},
|
|
};
|
|
}
|
|
|
|
test("a flush waiting on an unacknowledged write fails as soon as the session times out", async () => {
|
|
const t = await timedOutServerWithUnackedWrite(0x1020305a);
|
|
try {
|
|
await t.outcome;
|
|
} finally {
|
|
t.close();
|
|
}
|
|
});
|
|
|
|
test("a flush fails when a Connect from another endpoint follows a timeout", async () => {
|
|
const t = await timedOutServerWithUnackedWrite(0x1020305b);
|
|
try {
|
|
t.send(t.socketB, new CultNetRudpSession({ connectionId: 0x1020305b }).createConnect(0));
|
|
await t.outcome;
|
|
} finally {
|
|
t.close();
|
|
}
|
|
});
|
|
|
|
test("a flush fails when a Connect from the same endpoint follows a timeout", async () => {
|
|
const t = await timedOutServerWithUnackedWrite(0x1020305c);
|
|
try {
|
|
t.send(t.socketA, new CultNetRudpSession({ connectionId: 0x1020305c }).createConnect(0));
|
|
await t.outcome;
|
|
} finally {
|
|
t.close();
|
|
}
|
|
});
|
|
|
|
test("operation service sweeps a session that ran a handler once it idles out", async () => {
|
|
const server = await startCultNetOperationServer({
|
|
runtimeId: "sai-sidecar",
|
|
sessionIdleTimeoutMs: 300,
|
|
handler: (request): CultNetOperationResponseMessage => ({
|
|
schemaVersion: "cultnet.operation_response.v0",
|
|
messageId: request.messageId,
|
|
serviceId: request.serviceId,
|
|
operation: request.operation,
|
|
status: "ok",
|
|
payloadSchema: "gamecult.eve.plugin_abi.response.v1",
|
|
payloadEncoding: "messagepack-base64",
|
|
payload: request.payload,
|
|
diagnostics: [],
|
|
sourceRuntimeId: "sai-sidecar",
|
|
}),
|
|
});
|
|
const port = Number(new URL(server.endpoint).port);
|
|
const sockets: Socket[] = [];
|
|
const request: CultNetOperationRequestMessage = {
|
|
schemaVersion: "cultnet.operation_request.v0",
|
|
messageId: "after-handling",
|
|
serviceId: "sai.vn",
|
|
operation: "describe",
|
|
payloadSchema: "gamecult.eve.plugin_abi.request.v1",
|
|
payloadEncoding: "messagepack-base64",
|
|
payload: "gaZzY2hlbWE=",
|
|
};
|
|
const connectionId = 0x43554c54;
|
|
try {
|
|
// Sixty-four clients each run a handler and then go silent without a
|
|
// Disconnect. Only the idle sweep can free their slots.
|
|
const clients: { socket: Socket; peer: CultNetRudpSession; handled: () => boolean }[] = [];
|
|
for (let index = 0; index < 64; index += 1) {
|
|
const socket = await bindUdpSocket();
|
|
sockets.push(socket);
|
|
const peer = new CultNetRudpSession({ connectionId });
|
|
let handled = false;
|
|
let accepted = false;
|
|
socket.on("message", (wire) => {
|
|
const packet = decodeRudpPacket(wire);
|
|
if (packet.packetType === "accept" && !accepted) {
|
|
accepted = true;
|
|
peer.receive(packet, 0);
|
|
for (const out of peer.sendMany("schema", Buffer.from(encode(encodeCultNetMessageForWire(request, "cultnet.schema.v0"))), { reliable: true, ordered: true, nowMs: 0 })) {
|
|
socket.send(encodeRudpPacket(out), port, "127.0.0.1");
|
|
}
|
|
} else if (packet.channelId === "schema") {
|
|
handled = true;
|
|
}
|
|
});
|
|
socket.send(encodeRudpPacket(peer.createConnect(0)), port, "127.0.0.1");
|
|
clients.push({ socket, peer, handled: () => handled });
|
|
}
|
|
await waitFor(() => clients.every((c) => c.handled()), "every handler ran");
|
|
await new Promise((resolve) => setTimeout(resolve, 700));
|
|
const response = await invokeCultNetOperation(server.endpoint, request, { runtimeId: "eve-test", timeoutMs: 1000 });
|
|
assert.equal(response.messageId, "after-handling");
|
|
} finally {
|
|
for (const socket of sockets) socket.close();
|
|
await server.close();
|
|
}
|
|
});
|
|
|
|
test("a flush started after a reconnect waits on the new session", async () => {
|
|
const peerSocket = await bindUdpSocket();
|
|
const clientSocket = await bindUdpSocket();
|
|
const connectionId = 0x1020305b;
|
|
const client = new CultNetRudpSocketTransportConnection({
|
|
runtimeId: "rudp-client",
|
|
socket: clientSocket,
|
|
mode: "client",
|
|
remoteHost: "127.0.0.1",
|
|
remotePort: udpPort(peerSocket),
|
|
connectionId,
|
|
resendDelayMs: 1000,
|
|
resendPollMs: 1000,
|
|
});
|
|
const received: CultNetRudpPacket[] = [];
|
|
let clientPort = 0;
|
|
peerSocket.on("message", (wire, remote) => {
|
|
clientPort = remote.port;
|
|
received.push(decodeRudpPacket(wire));
|
|
});
|
|
try {
|
|
client.connect();
|
|
await waitFor(() => received.some((p) => p.packetType === "connect"), "the Connect");
|
|
const first = new CultNetRudpSession({ connectionId });
|
|
peerSocket.send(encodeRudpPacket(first.acceptConnect(received[0]!, 0)), clientPort, "127.0.0.1");
|
|
await waitFor(() => client.connected, "the client connected");
|
|
client.send("schema", Buffer.from("owed to the old session"));
|
|
client.checkTimeout(50, Date.now() + 10_000);
|
|
assert.equal(client.connected, false);
|
|
|
|
client.connect();
|
|
await waitFor(() => received.filter((p) => p.packetType === "connect").length === 2, "the reconnect");
|
|
const second = new CultNetRudpSession({ connectionId });
|
|
const reconnect = received.filter((p) => p.packetType === "connect")[1]!;
|
|
peerSocket.send(encodeRudpPacket(second.acceptConnect(reconnect, 0)), clientPort, "127.0.0.1");
|
|
await waitFor(() => client.connected, "the client reconnected");
|
|
await client.flush(500);
|
|
} finally {
|
|
peerSocket.close();
|
|
client.close();
|
|
}
|
|
});
|
|
|
|
test("a flush started after the server accepts a new Connect waits on the new session", async () => {
|
|
const serverSocket = await bindUdpSocket();
|
|
const peerSocket = await bindUdpSocket();
|
|
const connectionId = 0x1020305c;
|
|
const server = new CultNetRudpSocketTransportConnection({
|
|
runtimeId: "rudp-server",
|
|
socket: serverSocket,
|
|
mode: "server",
|
|
connectionId,
|
|
resendDelayMs: 1000,
|
|
resendPollMs: 1000,
|
|
});
|
|
const received: CultNetRudpPacket[] = [];
|
|
peerSocket.on("message", (wire) => received.push(decodeRudpPacket(wire)));
|
|
const toServer = (packet: CultNetRudpPacket) =>
|
|
peerSocket.send(encodeRudpPacket(packet), udpPort(serverSocket), "127.0.0.1");
|
|
try {
|
|
const first = new CultNetRudpSession({ connectionId });
|
|
toServer(first.createConnect(0));
|
|
await waitFor(() => received.some((p) => p.packetType === "accept"), "the first Accept");
|
|
toServer(first.createDisconnect(Buffer.from("bye")));
|
|
await waitFor(() => !server.connected, "the first session ended");
|
|
|
|
const next = new CultNetRudpSession({ connectionId });
|
|
toServer(next.createConnect(0));
|
|
await waitFor(() => received.filter((p) => p.packetType === "accept").length === 2, "the second Accept");
|
|
const accept = received.filter((p) => p.packetType === "accept")[1]!;
|
|
next.receive(accept, 0);
|
|
toServer(next.createAckForReceived(accept.sequence));
|
|
await waitFor(() => server.outstandingReliablePacketCount === 0, "the second Accept acknowledged");
|
|
|
|
await server.flush(500);
|
|
} finally {
|
|
peerSocket.close();
|
|
server.close();
|
|
}
|
|
});
|
|
|
|
test("server-mode transport admits a restarted client and keeps its session when a Connect is retransmitted", async () => {
|
|
const serverSocket = await bindUdpSocket();
|
|
const peerSocket = await bindUdpSocket();
|
|
const connectionId = 0x10203059;
|
|
const server = new CultNetRudpSocketTransportConnection({
|
|
runtimeId: "rudp-server",
|
|
socket: serverSocket,
|
|
mode: "server",
|
|
connectionId,
|
|
resendPollMs: 1000,
|
|
});
|
|
let received: CultNetRudpPacket[] = [];
|
|
peerSocket.on("message", (wire) => received.push(decodeRudpPacket(wire)));
|
|
const frames: string[] = [];
|
|
server.on("frame", (frame: { payload: Uint8Array }) => frames.push(Buffer.from(frame.payload).toString("utf8")));
|
|
const toServer = (packet: CultNetRudpPacket) =>
|
|
peerSocket.send(encodeRudpPacket(packet), udpPort(serverSocket), "127.0.0.1");
|
|
const reliable = { reliable: true, ordered: true };
|
|
try {
|
|
const first = new CultNetRudpSession({ connectionId, initialSequence: 50_000 });
|
|
const connect = first.createConnect(0, Buffer.from("same"));
|
|
toServer(connect);
|
|
await waitFor(() => received.some((p) => p.packetType === "accept"), "the first Accept");
|
|
first.receive(received.find((p) => p.packetType === "accept")!, 0);
|
|
const sent = [0, 1, 2].map((index) => first.send("schema", Buffer.from(`c${index}`), reliable));
|
|
sent.forEach(toServer);
|
|
await waitFor(() => frames.length === 3, "the first client's frames");
|
|
|
|
// A retransmitted Connect is a repeat: what was delivered stays delivered once.
|
|
toServer(connect);
|
|
toServer(sent[0]!);
|
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
assert.deepEqual(frames, ["c0", "c1", "c2"]);
|
|
|
|
// The same client restarted on the same address, connection id and payload.
|
|
received = [];
|
|
const second = new CultNetRudpSession({ connectionId, initialSequence: 7 });
|
|
toServer(second.createConnect(0, Buffer.from("same")));
|
|
await waitFor(() => received.some((p) => p.packetType === "accept"), "the restarted client's Accept");
|
|
second.receive(received.find((p) => p.packetType === "accept")!, 0);
|
|
toServer(second.send("schema", Buffer.from("after restart"), reliable));
|
|
await waitFor(() => frames.includes("after restart"), "the restarted client's frame");
|
|
} finally {
|
|
peerSocket.close();
|
|
server.close();
|
|
}
|
|
});
|
|
|
|
// A request belongs to the session generation it arrived on. A client that connects again
|
|
// from the same endpoint while a handler runs owns that endpoint now: nothing the handler
|
|
// returns or throws for the old generation may reach it.
|
|
async function restartDuringHandler(settle: (release: { resolve: () => void; reject: (error: Error) => void }) => void) {
|
|
let release: { resolve: () => void; reject: (error: Error) => void } | undefined;
|
|
const server = await startCultNetOperationServer({
|
|
runtimeId: "sai-sidecar",
|
|
handler: async (request) => {
|
|
if (request.messageId === "held") {
|
|
await new Promise<void>((resolve, reject) => { release = { resolve, reject: (error) => reject(error) }; });
|
|
}
|
|
return {
|
|
schemaVersion: "cultnet.operation_response.v0",
|
|
messageId: request.messageId,
|
|
serviceId: request.serviceId,
|
|
operation: request.operation,
|
|
status: "ok",
|
|
payloadSchema: "gamecult.eve.plugin_abi.response.v1",
|
|
payloadEncoding: "messagepack-base64",
|
|
payload: request.payload,
|
|
diagnostics: [],
|
|
sourceRuntimeId: "sai-sidecar",
|
|
};
|
|
},
|
|
});
|
|
const connectionId = 0x43554c54;
|
|
const port = Number(new URL(server.endpoint).port);
|
|
const socket = await bindUdpSocket();
|
|
let session = new CultNetRudpSession({ connectionId, initialSequence: 50_000 });
|
|
const answered: string[] = [];
|
|
const goodbyes: string[] = [];
|
|
socket.on("message", (wire) => {
|
|
const packet = decodeRudpPacket(wire);
|
|
if (packet.packetType === "disconnect") goodbyes.push(Buffer.from(packet.payload ?? []).toString("utf8"));
|
|
const result = session.receive(packet, Date.now());
|
|
if (packet.packetType === "accept" || packet.reliable) {
|
|
socket.send(encodeRudpPacket(session.createAckForReceived(packet.sequence)), port, "127.0.0.1");
|
|
}
|
|
for (const frame of result.delivered) {
|
|
answered.push((decodeMsgpack(frame.payload) as { messageId: string }).messageId);
|
|
}
|
|
});
|
|
const toServer = (packet: CultNetRudpPacket) => socket.send(encodeRudpPacket(packet), port, "127.0.0.1");
|
|
const request = (messageId: string) => Buffer.from(encode(encodeCultNetMessageForWire({
|
|
schemaVersion: "cultnet.operation_request.v0",
|
|
messageId,
|
|
serviceId: "sai.vn",
|
|
operation: "describe",
|
|
payloadSchema: "gamecult.eve.plugin_abi.request.v1",
|
|
payloadEncoding: "messagepack-base64",
|
|
payload: "gaZzY2hlbWE=",
|
|
} satisfies CultNetOperationRequestMessage)));
|
|
try {
|
|
toServer(session.createConnect(Date.now()));
|
|
await waitFor(() => session.connected, "the first Accept");
|
|
toServer(session.send("schema", request("held"), { reliable: true, ordered: true }));
|
|
await waitFor(() => release !== undefined, "the held handler");
|
|
|
|
session = new CultNetRudpSession({ connectionId, initialSequence: 7 });
|
|
toServer(session.createConnect(Date.now()));
|
|
await waitFor(() => session.connected, "the restarted client's Accept");
|
|
settle(release!);
|
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
toServer(session.send("schema", request("after-restart"), { reliable: true, ordered: true }));
|
|
await waitFor(() => answered.includes("after-restart"), "the restarted client's answer");
|
|
|
|
assert.deepEqual(answered, ["after-restart"], "the old generation's response reached the new session");
|
|
assert.deepEqual(goodbyes, [], "a goodbye for the old generation reached the new session");
|
|
assert.equal(session.connected, true);
|
|
} finally {
|
|
socket.close();
|
|
await server.close();
|
|
}
|
|
}
|
|
|
|
test("an operation response for a generation a new Connect replaced is not sent into the new session", async () => {
|
|
await restartDuringHandler((release) => release.resolve());
|
|
});
|
|
|
|
test("an operation handler that fails after a new Connect replaced its generation says no goodbye to the new session", async () => {
|
|
await restartDuringHandler((release) => release.reject(new Error("handler failed")));
|
|
});
|
|
|
|
test("RUDP operation service admits a restarted client on the same address", async () => {
|
|
const server = await startCultNetOperationServer({
|
|
runtimeId: "sai-sidecar",
|
|
handler: request => ({
|
|
schemaVersion: "cultnet.operation_response.v0",
|
|
messageId: request.messageId,
|
|
serviceId: request.serviceId,
|
|
operation: request.operation,
|
|
status: "ok",
|
|
payloadSchema: "gamecult.eve.plugin_abi.response.v1",
|
|
payloadEncoding: "messagepack-base64",
|
|
payload: request.payload,
|
|
diagnostics: [],
|
|
sourceRuntimeId: "sai-sidecar",
|
|
}),
|
|
});
|
|
const connectionId = 0x43554c54;
|
|
const port = Number(new URL(server.endpoint).port);
|
|
const socket = await bindUdpSocket();
|
|
let session = new CultNetRudpSession({ connectionId, initialSequence: 50_000 });
|
|
const answered: string[] = [];
|
|
socket.on("message", (wire) => {
|
|
const packet = decodeRudpPacket(wire);
|
|
const result = session.receive(packet, Date.now());
|
|
if (packet.packetType === "accept" || packet.reliable) {
|
|
socket.send(encodeRudpPacket(session.createAckForReceived(packet.sequence)), port, "127.0.0.1");
|
|
}
|
|
for (const frame of result.delivered) {
|
|
answered.push((decodeMsgpack(frame.payload) as { messageId: string }).messageId);
|
|
}
|
|
});
|
|
const toServer = (packet: CultNetRudpPacket) => socket.send(encodeRudpPacket(packet), port, "127.0.0.1");
|
|
const request = (messageId: string) => Buffer.from(encode(encodeCultNetMessageForWire({
|
|
schemaVersion: "cultnet.operation_request.v0",
|
|
messageId,
|
|
serviceId: "sai.vn",
|
|
operation: "describe",
|
|
payloadSchema: "gamecult.eve.plugin_abi.request.v1",
|
|
payloadEncoding: "messagepack-base64",
|
|
payload: "gaZzY2hlbWE=",
|
|
} satisfies CultNetOperationRequestMessage)));
|
|
try {
|
|
toServer(session.createConnect(Date.now()));
|
|
await waitFor(() => session.connected, "the first Accept");
|
|
toServer(session.send("schema", request("before-restart"), { reliable: true, ordered: true }));
|
|
await waitFor(() => answered.includes("before-restart"), "the first answer");
|
|
|
|
session = new CultNetRudpSession({ connectionId, initialSequence: 7 });
|
|
toServer(session.createConnect(Date.now()));
|
|
await waitFor(() => session.connected, "the restarted client's Accept");
|
|
toServer(session.send("schema", request("after-restart"), { reliable: true, ordered: true }));
|
|
await waitFor(() => answered.includes("after-restart"), "the restarted client's answer");
|
|
} finally {
|
|
socket.close();
|
|
await server.close();
|
|
}
|
|
});
|