REC-032 commit 1: move the canonical 33 TS + witness-artifact-bundle schema files (34 total) into runtime-neutral CultLib/contracts/cultnet, move the interop-note schema (and delete the zero-consumer discovery-* schemas per D3, tagged parked/cultnet-discovery-contracts) into contracts/cultnet/interop, and delete the 27 C# and 24 Rust duplicate copies after confirming byte-for-byte equivalence (only cultnet.snapshot-request differs: TS carries shardId/shardEpoch, a known drift already resolved in favor of the TS shape). Rewrite every $id from https://github.com/GameCult/cultnet-ts/contracts/ and .../cultnet-ts/integration/contracts/ to https://github.com/GameCult/CultLib/contracts/cultnet/(interop/); give the two shard-catalog schemas URL ids per D1, replacing the bare cultnet.shard_catalog_*.v0 identifiers; leave ghostlight.agent-state's Ghostlight-owned $id untouched per D5. Repoint every runtime's load path and id literal at the new location: C# csproj EmbeddedResource + LogicalName, Rust include_str! paths, TS contracts.ts imports + swarm-contracts manifest/tooling + package.json files/prepack, and the id/path literals in the C#, Rust, Python and Kotlin interop tests and peers. Python and Kotlin still build their own schema JSON in-process (D2); only their base constants, id literals, and (for Kotlin) contentHash literals were swapped. Every Kotlin contentHash was recomputed from the final canonical files using the runtime's own stable-stringify + SHA-256 function; all matched the planner's precomputed values except cultnet.schema-catalog-response, where the planner's proposed value did not match recomputation and the freshly computed value was used instead. REC-032, decisions D1/D2/D3/D5. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
361 lines
12 KiB
TypeScript
361 lines
12 KiB
TypeScript
import { readFile } from "node:fs/promises";
|
|
|
|
import { decode, encode } from "@msgpack/msgpack";
|
|
|
|
import { z } from "zod";
|
|
|
|
export const INTEROP_DOCUMENT_TYPE = "cultnet.interop-note";
|
|
export const INTEROP_SCHEMA_VERSION = "cultnet.interop_note.v0";
|
|
export const INTEROP_MUTATION_INTENT_DOCUMENT_TYPE = "cultnet.interop-note-mutation-intent";
|
|
export const INTEROP_MUTATION_INTENT_SCHEMA_ID = "https://github.com/GameCult/CultLib/contracts/cultnet/interop/cultnet.interop-note-mutation-intent.schema.json";
|
|
export const INTEROP_MUTATION_INTENT_SCHEMA_VERSION = "cultnet.interop_note_mutation_intent.v0";
|
|
export const INTEROP_MUTATION_RECEIPT_DOCUMENT_TYPE = "cultnet.interop-note-mutation-receipt";
|
|
export const INTEROP_MUTATION_RECEIPT_SCHEMA_ID = "https://github.com/GameCult/CultLib/contracts/cultnet/interop/cultnet.interop-note-mutation-receipt.schema.json";
|
|
export const INTEROP_MUTATION_RECEIPT_SCHEMA_VERSION = "cultnet.interop_note_mutation_receipt.v0";
|
|
export const INTEROP_FIRE_COMMAND_DOCUMENT_TYPE = "cultnet.interop-fire-weapon-command";
|
|
export const INTEROP_FIRE_COMMAND_SCHEMA_ID = "https://github.com/GameCult/CultLib/contracts/cultnet/interop/cultnet.interop-fire-weapon-command.schema.json";
|
|
export const INTEROP_FIRE_COMMAND_SCHEMA_VERSION = "cultnet.interop_fire_weapon_command.v0";
|
|
export const INTEROP_FIRE_RECEIPT_DOCUMENT_TYPE = "cultnet.interop-fire-weapon-receipt";
|
|
export const INTEROP_FIRE_RECEIPT_SCHEMA_ID = "https://github.com/GameCult/CultLib/contracts/cultnet/interop/cultnet.interop-fire-weapon-receipt.schema.json";
|
|
export const INTEROP_FIRE_RECEIPT_SCHEMA_VERSION = "cultnet.interop_fire_weapon_receipt.v0";
|
|
export const INTEROP_WIRE_CONTRACT = "cultnet.schema.v0" as const;
|
|
export const DISCOVERY_PROBE_SCHEMA_VERSION = "cultnet.discovery_probe.v0";
|
|
export const DISCOVERY_ANNOUNCE_SCHEMA_VERSION = "cultnet.discovery_announce.v0";
|
|
|
|
export const interopNoteSchema = z.object({
|
|
schemaVersion: z.literal(INTEROP_SCHEMA_VERSION),
|
|
documentId: z.string().min(1),
|
|
authorRuntimeId: z.string().min(1),
|
|
title: z.string().min(1),
|
|
body: z.string().min(1),
|
|
tags: z.array(z.string().min(1)),
|
|
});
|
|
|
|
export type InteropNote = z.infer<typeof interopNoteSchema>;
|
|
|
|
export const interopMutationIntentSchema = z.object({
|
|
schemaVersion: z.literal(INTEROP_MUTATION_INTENT_SCHEMA_VERSION),
|
|
intentId: z.string().min(1),
|
|
targetDocumentId: z.string().min(1),
|
|
appendBody: z.string().min(1),
|
|
appendTag: z.string().min(1),
|
|
});
|
|
|
|
export type InteropMutationIntent = z.infer<typeof interopMutationIntentSchema>;
|
|
|
|
export const interopMutationReceiptSchema = z.object({
|
|
schemaVersion: z.literal(INTEROP_MUTATION_RECEIPT_SCHEMA_VERSION),
|
|
intentId: z.string().min(1),
|
|
accepted: z.boolean(),
|
|
documentId: z.string().min(1),
|
|
body: z.string(),
|
|
tags: z.array(z.string().min(1)),
|
|
error: z.string().optional(),
|
|
});
|
|
|
|
export type InteropMutationReceipt = z.infer<typeof interopMutationReceiptSchema>;
|
|
|
|
export const interopFireCommandSchema = z.object({
|
|
schemaVersion: z.literal(INTEROP_FIRE_COMMAND_SCHEMA_VERSION),
|
|
commandId: z.string().min(1),
|
|
characterId: z.string().min(1),
|
|
weaponId: z.string().min(1),
|
|
});
|
|
|
|
export type InteropFireCommand = z.infer<typeof interopFireCommandSchema>;
|
|
|
|
export const interopFireReceiptSchema = z.object({
|
|
schemaVersion: z.literal(INTEROP_FIRE_RECEIPT_SCHEMA_VERSION),
|
|
commandId: z.string().min(1),
|
|
accepted: z.boolean(),
|
|
characterId: z.string().min(1),
|
|
weaponId: z.string().min(1),
|
|
shotsFired: z.number().int().nonnegative(),
|
|
ammoRemaining: z.number().int().nonnegative(),
|
|
error: z.string().optional(),
|
|
});
|
|
|
|
export type InteropFireReceipt = z.infer<typeof interopFireReceiptSchema>;
|
|
|
|
export interface SlotFormatter<T> {
|
|
encode(value: T): Uint8Array;
|
|
decode(payload: Uint8Array): T;
|
|
}
|
|
|
|
export function createInteropFormatter(): SlotFormatter<InteropNote> {
|
|
return slotFormatter<InteropNote>("Interop note", 5, (value) => [
|
|
value.schemaVersion,
|
|
value.documentId,
|
|
value.authorRuntimeId,
|
|
value.title,
|
|
value.body,
|
|
value.tags,
|
|
], ([schemaVersion, documentId, authorRuntimeId, title, body, tags]) => interopNoteSchema.parse({
|
|
schemaVersion,
|
|
documentId,
|
|
authorRuntimeId,
|
|
title,
|
|
body,
|
|
tags: Array.isArray(tags) ? tags : [],
|
|
}));
|
|
}
|
|
|
|
export function createInteropMutationIntentFormatter(): SlotFormatter<InteropMutationIntent> {
|
|
return slotFormatter<InteropMutationIntent>("Interop mutation intent", 5, (value) => [
|
|
value.schemaVersion,
|
|
value.intentId,
|
|
value.targetDocumentId,
|
|
value.appendBody,
|
|
value.appendTag,
|
|
], ([schemaVersion, intentId, targetDocumentId, appendBody, appendTag]) => interopMutationIntentSchema.parse({
|
|
schemaVersion,
|
|
intentId,
|
|
targetDocumentId,
|
|
appendBody,
|
|
appendTag,
|
|
}));
|
|
}
|
|
|
|
export function createInteropMutationReceiptFormatter(): SlotFormatter<InteropMutationReceipt> {
|
|
return slotFormatter<InteropMutationReceipt>("Interop mutation receipt", 6, (value) => [
|
|
value.schemaVersion,
|
|
value.intentId,
|
|
value.accepted,
|
|
value.documentId,
|
|
value.body,
|
|
value.tags,
|
|
value.error,
|
|
], ([schemaVersion, intentId, accepted, documentId, body, tags, error]) => interopMutationReceiptSchema.parse({
|
|
schemaVersion,
|
|
intentId,
|
|
accepted,
|
|
documentId,
|
|
body,
|
|
tags: Array.isArray(tags) ? tags : [],
|
|
error: typeof error === "string" ? error : undefined,
|
|
}));
|
|
}
|
|
|
|
export function createInteropFireCommandFormatter(): SlotFormatter<InteropFireCommand> {
|
|
return slotFormatter<InteropFireCommand>("Interop fire command", 4, (value) => [
|
|
value.schemaVersion,
|
|
value.commandId,
|
|
value.characterId,
|
|
value.weaponId,
|
|
], ([schemaVersion, commandId, characterId, weaponId]) => interopFireCommandSchema.parse({
|
|
schemaVersion,
|
|
commandId,
|
|
characterId,
|
|
weaponId,
|
|
}));
|
|
}
|
|
|
|
export function createInteropFireReceiptFormatter(): SlotFormatter<InteropFireReceipt> {
|
|
return slotFormatter<InteropFireReceipt>("Interop fire receipt", 6, (value) => [
|
|
value.schemaVersion,
|
|
value.commandId,
|
|
value.accepted,
|
|
value.characterId,
|
|
value.weaponId,
|
|
value.shotsFired,
|
|
value.ammoRemaining,
|
|
value.error,
|
|
], ([schemaVersion, commandId, accepted, characterId, weaponId, shotsFired, ammoRemaining, error]) => interopFireReceiptSchema.parse({
|
|
schemaVersion,
|
|
commandId,
|
|
accepted,
|
|
characterId,
|
|
weaponId,
|
|
shotsFired,
|
|
ammoRemaining,
|
|
error: typeof error === "string" ? error : undefined,
|
|
}));
|
|
}
|
|
|
|
function slotFormatter<T>(
|
|
name: string,
|
|
minimumSlots: number,
|
|
encodeSlots: (value: T) => unknown[],
|
|
decodeSlots: (slots: unknown[]) => T,
|
|
): SlotFormatter<T> {
|
|
return {
|
|
encode(value: T): Uint8Array {
|
|
return encode(encodeSlots(value));
|
|
},
|
|
decode(payload: Uint8Array): T {
|
|
const decoded = decode(payload);
|
|
if (!Array.isArray(decoded) || decoded.length < minimumSlots) {
|
|
throw new Error(`${name} payload must be a MessagePack array with at least ${minimumSlots} slots.`);
|
|
}
|
|
|
|
return decodeSlots(decoded);
|
|
},
|
|
};
|
|
}
|
|
|
|
export function createLegacyInteropNoteFormatter(): SlotFormatter<InteropNote> {
|
|
return {
|
|
encode(value: InteropNote): Uint8Array {
|
|
return encode([
|
|
value.schemaVersion,
|
|
value.documentId,
|
|
value.authorRuntimeId,
|
|
value.title,
|
|
value.body,
|
|
]);
|
|
},
|
|
decode: createInteropFormatter().decode,
|
|
};
|
|
}
|
|
|
|
export function createMismatchedInteropNoteFormatter(): SlotFormatter<InteropNote> {
|
|
return {
|
|
encode(value: InteropNote): Uint8Array {
|
|
return encode([
|
|
value.schemaVersion,
|
|
value.documentId,
|
|
42,
|
|
value.title,
|
|
value.body,
|
|
value.tags,
|
|
]);
|
|
},
|
|
decode: createInteropFormatter().decode,
|
|
};
|
|
}
|
|
|
|
export const discoveryProbeSchema = z.object({
|
|
schemaVersion: z.literal(DISCOVERY_PROBE_SCHEMA_VERSION),
|
|
messageId: z.string().min(1),
|
|
requesterRuntimeId: z.string().min(1),
|
|
});
|
|
|
|
export type DiscoveryProbe = z.infer<typeof discoveryProbeSchema>;
|
|
|
|
export const discoveryTransportProfileSchema = z.object({
|
|
schemaVersion: z.literal("cultnet.transport_profile.v0"),
|
|
runtimeId: z.string().min(1),
|
|
transports: z.array(z.object({
|
|
transportId: z.string().min(1),
|
|
protocol: z.enum(["tcp_framed", "litenetlib", "websocket", "rudp"]),
|
|
host: z.string().min(1).nullable().optional(),
|
|
port: z.number().int().min(1).max(65535).nullable().optional(),
|
|
path: z.string().min(1).nullable().optional(),
|
|
discoveryGroup: z.string().min(1).nullable().optional(),
|
|
wireContracts: z.array(z.string().min(1)).nullable().optional(),
|
|
reconnectPolicy: z.object({
|
|
schemaVersion: z.literal("cultnet.reconnect_policy.v0"),
|
|
policyId: z.string().min(1),
|
|
baseDelayMs: z.number().int().min(0),
|
|
maxDelayMs: z.number().int().min(0),
|
|
maxJitterMs: z.number().int().min(0),
|
|
maxAttempts: z.number().int().min(1).nullable().optional(),
|
|
}).nullable().optional(),
|
|
channels: z.array(z.object({
|
|
channelId: z.string().min(1),
|
|
delivery: z.enum(["reliable", "unreliable"]),
|
|
ordering: z.enum(["ordered", "unordered", "sequenced"]),
|
|
maxPayloadBytes: z.number().int().min(1).nullable().optional(),
|
|
maxFragmentBytes: z.number().int().min(1).nullable().optional(),
|
|
maxPendingReliablePackets: z.number().int().min(1).nullable().optional(),
|
|
})),
|
|
})),
|
|
});
|
|
|
|
export const discoveryAnnounceSchema = z.object({
|
|
schemaVersion: z.literal(DISCOVERY_ANNOUNCE_SCHEMA_VERSION),
|
|
messageId: z.string().min(1),
|
|
runtimeId: z.string().min(1),
|
|
runtimeKind: z.string().min(1),
|
|
displayName: z.string().min(1),
|
|
agentId: z.string().min(1).optional(),
|
|
tcpHost: z.string().min(1),
|
|
tcpPort: z.number().int().positive().max(65535),
|
|
wireContract: z.literal(INTEROP_WIRE_CONTRACT),
|
|
supportedDocumentTypes: z.array(z.string().min(1)),
|
|
transportProfiles: z.array(discoveryTransportProfileSchema).optional(),
|
|
supportsSchemaCatalog: z.boolean(),
|
|
});
|
|
|
|
export type DiscoveryAnnounce = z.infer<typeof discoveryAnnounceSchema>;
|
|
|
|
export interface LoadedSchemaRegistration {
|
|
schemaId: string;
|
|
title?: string;
|
|
schemaJson: string;
|
|
schema: object;
|
|
}
|
|
|
|
export async function loadInteropSchemaRegistration(schemaPath: string): Promise<LoadedSchemaRegistration> {
|
|
const schemaJson = await readFile(schemaPath, "utf8");
|
|
const parsed = JSON.parse(schemaJson) as {
|
|
$id?: string;
|
|
title?: string;
|
|
};
|
|
|
|
if (!parsed.$id) {
|
|
throw new Error(`Interop schema at ${schemaPath} is missing $id.`);
|
|
}
|
|
|
|
return {
|
|
schemaId: parsed.$id,
|
|
title: parsed.title,
|
|
schemaJson,
|
|
schema: JSON.parse(schemaJson) as object,
|
|
};
|
|
}
|
|
|
|
export function buildInteropNote(runtimeId: string, displayName: string): InteropNote {
|
|
return {
|
|
schemaVersion: INTEROP_SCHEMA_VERSION,
|
|
documentId: `note:${runtimeId}`,
|
|
authorRuntimeId: runtimeId,
|
|
title: `${displayName} keeps a little note`,
|
|
body: `${runtimeId} can move CultNet state without begging the gods for translation.`,
|
|
tags: [runtimeId, "interop", "cultnet"],
|
|
};
|
|
}
|
|
|
|
export function parseArgs(argv: readonly string[]): Map<string, string> {
|
|
const args = new Map<string, string>();
|
|
for (let index = 0; index < argv.length; index += 1) {
|
|
const token = argv[index];
|
|
if (!token.startsWith("--")) {
|
|
continue;
|
|
}
|
|
|
|
const name = token.slice(2);
|
|
const value = argv[index + 1];
|
|
if (!value || value.startsWith("--")) {
|
|
throw new Error(`Missing value for --${name}`);
|
|
}
|
|
|
|
args.set(name, value);
|
|
index += 1;
|
|
}
|
|
|
|
return args;
|
|
}
|
|
|
|
export function requireArg(args: Map<string, string>, name: string): string {
|
|
const value = args.get(name);
|
|
if (!value) {
|
|
throw new Error(`Missing required argument --${name}`);
|
|
}
|
|
|
|
return value;
|
|
}
|
|
|
|
export function optionalIntArg(args: Map<string, string>, name: string, fallback?: number): number | undefined {
|
|
const raw = args.get(name);
|
|
if (!raw) {
|
|
return fallback;
|
|
}
|
|
|
|
const parsed = Number.parseInt(raw, 10);
|
|
if (!Number.isInteger(parsed)) {
|
|
throw new Error(`Argument --${name} must be an integer.`);
|
|
}
|
|
|
|
return parsed;
|
|
}
|