cultnet-ts: cultnet.error.v0 keeps routingHint, code and details, judged against the reference's bytes

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Libby 2026-11-07 01:36:25 +00:00
commit 1d21a93a2d
3 changed files with 84 additions and 0 deletions

View file

@ -165,9 +165,37 @@ export interface CultNetLoginSuccessMessage {
session: string;
}
/** The closed `code` enum of `cultnet.error.schema.json`; the schema refuses any other string. */
export type CultNetErrorCode =
| "selection_invalid"
| "cursor_stale"
| "cursor_invalid"
| "reference_outside_target"
| "variant_unsupported"
| "unowned_schema"
| "write_not_authorized";
/** Code-specific detail; each field is populated only for the code that defines it, null otherwise. */
export interface CultNetErrorDetails {
field: string | null;
value: string | null;
asOf: number | null;
current: number | null;
}
/** Shard routing detail for an authority failure. */
export interface CultNetShardRoutingHint {
shard: Record<string, unknown> | null;
reason: string;
}
/** The reference writes all five keys, nil included, so a received error always carries them. */
export interface CultNetErrorMessage {
schemaVersion: "cultnet.error.v0";
error: string;
routingHint: CultNetShardRoutingHint | null;
code: CultNetErrorCode | null;
details: CultNetErrorDetails | null;
}
export interface CultNetSampleChangeNameMessage {
@ -842,6 +870,9 @@ function parseGameCultNetworkingMessage(input: unknown): CultNetMessage {
return {
schemaVersion: "cultnet.error.v0",
error: requireLegacyString(payload[0], "ErrorMessage.Error"),
routingHint: null,
code: null,
details: null,
};
case 5:
return {
@ -898,6 +929,7 @@ function encodeGameCultNetworkingMessage(message: CultNetMessage): [number, unkn
decodeBase64Url(message.session, "LoginSuccessMessage.Session"),
]];
case "cultnet.error.v0":
// The gamecult.networking.v0 union's ErrorMessage carries only the text.
return [4, [message.error]];
case "cultnet.sample.change_name.v0":
return [5, [message.name]];

View file

@ -13,7 +13,10 @@ export {
type CultNetDatabaseChangeRawMessage,
type CultNetRawDocumentRecord,
type CultNetRawPayloadEncoding,
type CultNetErrorCode,
type CultNetErrorDetails,
type CultNetErrorMessage,
type CultNetShardRoutingHint,
type CultNetHelloMessage,
type CultNetLoginMessage,
type CultNetLoginSuccessMessage,

View file

@ -0,0 +1,49 @@
// The reference's own bytes for the cultnet.error.v0 code/details shape and the selection/v1
// carriers (`contracts/cultnet/interop/carrier-vectors.cs-written.json`, written by the C#
// CarrierVectorTests). Each vector is decoded, parsed, and re-encoded: the re-encoding must be the
// reference's exact bytes, and the decoded message must equal the reference's own JSON of the object.
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { decode, encode } from "@msgpack/msgpack";
import { encodeCultNetMessageForWire, parseCultNetMessage } from "../src/contracts";
const FILE = join(__dirname, "..", "..", "..", "..", "contracts", "cultnet", "interop", "carrier-vectors.cs-written.json");
type Vector = { name: string; schemaVersion: string; messagePackBase64: string; expected: unknown };
const vectors: Vector[] = JSON.parse(readFileSync(FILE, "utf8")).vectors;
const vectorsFor = (prefix: string) => vectors.filter(v => v.name.startsWith(prefix));
const bytesOf = (v: Vector) => new Uint8Array(Buffer.from(v.messagePackBase64, "base64"));
/** Uint8Array to base64, recursively, so a decoded message compares with the reference's JSON. */
function jsonShape(value: unknown): unknown {
if (value instanceof Uint8Array) return Buffer.from(value).toString("base64");
if (Array.isArray(value)) return value.map(jsonShape);
if (value && typeof value === "object") {
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, jsonShape(v)]));
}
return value;
}
const reencode = (message: Parameters<typeof encodeCultNetMessageForWire>[0]) =>
new Uint8Array(encode(encodeCultNetMessageForWire(message), { ignoreUndefined: true }));
test("every error vector keeps the reference's code and details through parse and re-encode", () => {
const errors = vectorsFor("error_");
assert.ok(errors.length >= 5);
for (const v of errors) {
const raw = decode(bytesOf(v));
assert.deepEqual(jsonShape(raw), v.expected, `${v.name}: decoded bytes`);
const message = parseCultNetMessage(raw);
assert.equal(message.schemaVersion, "cultnet.error.v0");
assert.deepEqual(jsonShape(message), v.expected, `${v.name}: parsed message`);
assert.deepEqual(reencode(message), bytesOf(v), `${v.name}: re-encoded bytes`);
}
});
test("an error whose code is outside the schema's closed enum is refused", () => {
const raw = decode(bytesOf(vectors.find(v => v.name === "error_cursor_stale")!)) as Record<string, unknown>;
assert.throws(() => parseCultNetMessage({ ...raw, code: "made_up_code" }), /cultnet\.error\.v0/);
});