From 8777ce4766cd59b628c30179f52026f6147fb1bd Mon Sep 17 00:00:00 2001 From: Libby Date: Fri, 6 Nov 2026 12:20:06 +0000 Subject: [PATCH] docs/start: two-client .io game recipe over CultCache + CultMesh (TS) Authority holds one arena document in a CultCache and serves it over CultMesh; two cultmesh-browser clients join, move and leave, and see each other. Tested with real WebSockets. Browser run, persistence, remote trust and CI wiring are marked not yet on the recipe page. Co-Authored-By: Claude Sonnet 5.5 --- docs/start/README.md | 2 +- docs/start/io-game.md | 196 +++++++++++++++++++++++++++ docs/start/io-game/arena.test.ts | 73 ++++++++++ docs/start/io-game/browser.ts | 17 +++ docs/start/io-game/client.ts | 28 ++++ docs/start/io-game/index.html | 6 + docs/start/io-game/package-lock.json | 64 +++++++++ docs/start/io-game/package.json | 9 ++ docs/start/io-game/schema.ts | 16 +++ docs/start/io-game/serve.ts | 17 +++ docs/start/io-game/server.ts | 100 ++++++++++++++ 11 files changed, 527 insertions(+), 1 deletion(-) create mode 100644 docs/start/io-game.md create mode 100644 docs/start/io-game/arena.test.ts create mode 100644 docs/start/io-game/browser.ts create mode 100644 docs/start/io-game/client.ts create mode 100644 docs/start/io-game/index.html create mode 100644 docs/start/io-game/package-lock.json create mode 100644 docs/start/io-game/package.json create mode 100644 docs/start/io-game/schema.ts create mode 100644 docs/start/io-game/serve.ts create mode 100644 docs/start/io-game/server.ts diff --git a/docs/start/README.md b/docs/start/README.md index 99b66354..3c7d9b7e 100644 --- a/docs/start/README.md +++ b/docs/start/README.md @@ -17,7 +17,7 @@ this page. | | CultCache | CultMath | Geometry | CultNet | CultMesh | | --- | --- | --- | --- | --- | --- | | **C#** | [first call, tested](#c-first-call) | [package](../../packages/cultmath/README.md); snippet not yet | [package](../../src/GameCult.Geometry/README.md); snippet not yet | [package](../../src/GameCult.Networking); snippet not yet | [quickstart](../../src/GameCult.Mesh/docs/durable-node-quickstart.md); run by `scripts/verify-eve-getting-started.ps1` | -| **TypeScript** | [first call, tested](#typescript-first-call) | not yet | not yet | [package](../../packages/cultnet-ts/README.md); snippet not yet | [package](../../packages/cultmesh-ts/README.md); snippet not yet | +| **TypeScript** | [first call, tested](#typescript-first-call) | not yet | not yet | [package](../../packages/cultnet-ts/README.md); snippet not yet | [package](../../packages/cultmesh-ts/README.md); snippet not yet; [recipe: two-client .io game](io-game.md), tested with two live clients, browser page not yet run | | **Rust** | [first call, tested](#rust-first-call) | [native core](../../packages/cultmath/native/cultmath-core); snippet not yet | not yet | [package](../../packages/cultnet-rs/README.md), [examples](../../packages/cultnet-rs/examples); snippet not yet | [package](../../packages/cultmesh-rs); snippet not yet | | **Python** | [first call, tested](#python-first-call) | not yet | not yet | [package](../../packages/cultnet-py/README.md); snippet not yet | [package](../../packages/cultmesh-py/README.md); snippet not yet | diff --git a/docs/start/io-game.md b/docs/start/io-game.md new file mode 100644 index 00000000..4781d9bf --- /dev/null +++ b/docs/start/io-game.md @@ -0,0 +1,196 @@ +# Recipe: a two-client .io game over CultCache + CultMesh (TypeScript) + +A tiny arena. One process is the authority: it holds a single `io.arena_state` document in a CultCache +and serves it over CultMesh. Each client discovers the authority through the Odin catalog, leases the +document, and invokes `join`, `move` and `leave` operations. Two clients see each other join, move and +leave because both lease the same document. + +## What ran, what did not + +| Piece | Status | +| --- | --- | +| Two clients, real WebSockets, real `cultmesh-browser` client, one arena: join, see each other, move, leave | **tested** (`arena.test.ts`) | +| Edge clamp, and the source below matching the files that ran | **tested** | +| Browser bundle builds and the page is served | **tested** (HTTP fetch only) | +| The page running in a real browser, two windows | **not yet**: no browser on the host that built this recipe | +| Run by CI / `npm run test:ts` | **not yet**: the recipe has its own `package.json` (for `ws`) outside the workspaces; run it by hand as below | +| Authority state on disk (`.cc` file) | **not yet**: the arena cache is in memory | +| Remote play: Odin roots and a signed route | **not yet**: the client uses `local-development` trust, loopback only | +| Dropped players: a closed tab that never sent `leave` stays in the arena | **not yet**: operations do not carry a connection identity to the authority | +| `CultCache` in the browser | **not yet**: the `@gamecult/cultcache-ts` entry imports Node built-ins, so the client keeps the decoded document in a variable and only `./document` is imported | + +No `cultmesh-ts` or `cultnet-ts` TypeScript authority server exists. The server below is the CultNet +message handling a provider owes, written in this recipe with the `ws` package. + +## Run it + +``` +npm ci --ignore-scripts # repo root +(cd packages/cultnet-ts && npm run build) # then cultcache-ts, cultmesh-browser, same command +cd docs/start/io-game && npm install +npm test # node --test arena.test.ts, Node 24 +node serve.ts # http://127.0.0.1:8080, open it twice +``` + +Tested on Node v24.14, which runs `.ts` files directly (no type check; `tsc` is not part of this recipe). + +## schema.ts + +```ts +import { defineDocumentType } from "@gamecult/cultcache-ts/document"; + +export interface Player { x: number; y: number } +export interface ArenaState { players: Record } + +// The one shared document: every player's position. The server owns it; clients lease it. +export const arenaDocument = defineDocumentType({ + type: "io.arena_state", + schemaId: "io.arena_state.v1", + schema: { parse: (value: unknown) => value as ArenaState }, +}); + +export const arenaKey = "arena:main"; +export const verseId = "io.arena"; +export const authorityRuntimeId = "io.arena-daemon"; +export const arenaSize = 100; +``` + +## server.ts + +```ts +import { createServer, type RequestListener, type Server } from "node:http"; +import { CultCache } from "@gamecult/cultcache-ts"; +import { defineDocumentRegistry } from "@gamecult/cultcache-ts/document"; +import { decodeWireValue, encodeWireValue } from "cultnet-ts/wire"; +import { encodeCultNetMessageForWire, parseCultNetMessage, type CultNetMessage } from "cultnet-ts/contracts"; +import { WebSocketServer, type WebSocket } from "ws"; +import { arenaDocument, arenaKey, arenaSize, authorityRuntimeId, verseId, type ArenaState } from "./schema.ts"; + +const bytesToBase64 = (b: Uint8Array) => Buffer.from(b).toString("base64"); +const base64ToBytes = (s: string) => new Uint8Array(Buffer.from(s, "base64")); + +// One process plays both CultMesh roles: Odin (/odin tells clients where the authority is) and the authority (/mesh). +export async function startArena(port = 0, host = "127.0.0.1", onRequest?: RequestListener) { + const cache = CultCache.builder().withRegistry(defineDocumentRegistry(arenaDocument)).build(); + await cache.put(arenaDocument, arenaKey, { players: {} }); + const http: Server = createServer(onRequest ?? ((_, res) => { res.writeHead(404).end(); })); + await new Promise(resolve => http.listen(port, host, resolve)); + const endpoint = `ws://${host}:${(http.address() as { port: number }).port}`; + + const subscribers = new Map(); + const send = (ws: WebSocket, m: CultNetMessage) => ws.send(encodeWireValue(encodeCultNetMessageForWire(m))); + const record = () => ({ + schemaId: arenaDocument.schemaId, schemaName: arenaDocument.type, schemaVersion: arenaDocument.schemaId, + schemaContentHash: arenaDocument.schemaId, recordKey: arenaKey, storedAt: new Date().toISOString(), + payloadEncoding: "messagepack" as const, + payload: encodeWireValue(cache.getRequired(arenaDocument, arenaKey)) as never, + sourceRuntimeId: authorityRuntimeId, + }); + const publish = async (next: ArenaState) => { + await cache.put(arenaDocument, arenaKey, next); + for (const [ws, ids] of subscribers) for (const subscriptionId of ids) { + send(ws, { schemaVersion: "cultnet.database_change_raw.v0", messageId: crypto.randomUUID(), subscriptionId, changeKind: "updated", document: record() }); + } + }; + + let nextId = 0; + const operations: Record Promise> = { + join: async () => { + const id = `p${++nextId}`; + const state = cache.getRequired(arenaDocument, arenaKey); + await publish({ players: { ...state.players, [id]: { x: Math.floor(Math.random() * arenaSize), y: Math.floor(Math.random() * arenaSize) } } }); + return { id }; + }, + move: async ({ id, dx, dy }: { id: string; dx: number; dy: number }) => { + const state = cache.getRequired(arenaDocument, arenaKey); + const me = state.players[id]; + if (!me) return { ok: false }; + const clamp = (v: number) => Math.max(0, Math.min(arenaSize, v)); + await publish({ players: { ...state.players, [id]: { x: clamp(me.x + Math.sign(dx)), y: clamp(me.y + Math.sign(dy)) } } }); + return { ok: true }; + }, + leave: async ({ id }: { id: string }) => { + const { [id]: _gone, ...rest } = cache.getRequired(arenaDocument, arenaKey).players; + await publish({ players: rest }); + return { ok: true }; + }, + }; + + const wss = new WebSocketServer({ server: http }); + wss.on("connection", (ws, req) => { + const odin = req.url === "/odin"; + ws.on("close", () => subscribers.delete(ws)); + ws.on("message", async raw => { + const m = parseCultNetMessage(decodeWireValue(new Uint8Array(raw as Buffer))); + if (odin && m.schemaVersion === "cultmesh.verse_catalog_request.v0") { + send(ws, { + schemaVersion: "cultmesh.verse_catalog_response.v0", messageId: m.messageId, + verses: [{ + verseId, displayName: "Arena", authorityModel: "ServerAuthoritative", + compatibility: { transportVersion: "cultmesh.v1", rulesHash: "arena-v1", compatibleVerseIds: [], requiredPluginIds: [], optionalPluginIds: [] }, + discoveryEndpoints: [`${endpoint}/mesh`], authorityRuntimeIds: [authorityRuntimeId], + authorityRoutes: [{ authorityRuntimeId, endpoint: `${endpoint}/mesh`, protocolIds: ["cultmesh.documents.v1"], priority: 0, generation: "arena-1" }], + }], + }); + } else if (m.schemaVersion === "cultmesh.session_open.v2") { + send(ws, { schemaVersion: "cultmesh.session_accepted.v2", messageId: m.messageId, accepted: true, verseId: m.verseId, authorityRuntimeId, + protocolId: m.protocolId, routeGeneration: m.routeGeneration, clientNonce: m.clientNonce, providerKeyId: "", providerSignature: "" }); + } else if (m.schemaVersion === "cultnet.database_subscribe.v0") { + subscribers.set(ws, [...(subscribers.get(ws) ?? []), m.subscriptionId]); + send(ws, { schemaVersion: "cultnet.snapshot_response_raw.v0", messageId: m.messageId, documents: [record()] }); + } else if (m.schemaVersion === "cultnet.database_unsubscribe.v0") { + subscribers.set(ws, (subscribers.get(ws) ?? []).filter(id => id !== m.subscriptionId)); + } else if (m.schemaVersion === "cultnet.operation_request.v0") { + const handler = operations[m.operation]; + const receipt = handler ? await handler(decodeWireValue(base64ToBytes(m.payload))) : undefined; + send(ws, { + schemaVersion: "cultnet.operation_response.v0", messageId: m.messageId, serviceId: m.serviceId, operation: m.operation, + status: handler ? "accepted" : "invalid", payloadSchema: "io.receipt.v1", payloadEncoding: "messagepack-base64", + payload: bytesToBase64(encodeWireValue(receipt ?? { ok: false })), sourceRuntimeId: authorityRuntimeId, + }); + } + }); + }); + + return { + odinUrl: `${endpoint}/odin`, + state: () => cache.getRequired(arenaDocument, arenaKey), + close: async () => { for (const c of wss.clients) c.terminate(); wss.close(); await new Promise(r => http.close(r)); }, + }; +} +``` + +## client.ts + +```ts +import { CultMeshBrowserClient, CultMeshBrowserOdinRendezvous, decodeCultNetOperationPayload, decodeCultNetPayload } from "cultmesh-browser"; +import { arenaDocument, arenaKey, authorityRuntimeId, verseId, type ArenaState } from "./schema.ts"; + +// Runs unchanged in a browser or in Node 22+: both have a global WebSocket. +export async function joinArena(odinUrl: string, runtimeId: string, onState: (state: ArenaState, myId: string) => void) { + const rendezvous = new CultMeshBrowserOdinRendezvous({ endpoints: [odinUrl], runtimeId: `${runtimeId}.discovery` }); + const mesh = await CultMeshBrowserClient.connect({ + verseId, authorityRuntimeId, runtimeId, rendezvous, + trust: { mode: "local-development" }, // loopback only; remote Verses need Odin roots + }); + let latest: ArenaState | undefined; // CultCache itself pulls in Node built-ins, so the browser client keeps the decoded document + const call = async (operation: string, payload: object) => + decodeCultNetOperationPayload<{ id?: string }>(await mesh.invoke({ serviceId: "io.arena", operation, payloadSchema: "io.request.v1", payload })); + + const lease = await mesh.leaseRawDocument({ schemaId: arenaDocument.schemaId, recordKey: arenaKey }); + const { id } = await call("join", {}); + lease.watch(record => { + if (!record) return; + latest = decodeCultNetPayload(record, arenaDocument); + onState(latest, id!); + }); + return { + id: id!, + move: (dx: number, dy: number) => call("move", { id, dx, dy }), + state: () => latest, + leave: async () => { await call("leave", { id }); lease.dispose(); await mesh.dispose(); }, + }; +} +``` + +`browser.ts`, `index.html` and `serve.ts` in [`io-game/`](io-game/) draw the canvas and bundle the client with esbuild. diff --git a/docs/start/io-game/arena.test.ts b/docs/start/io-game/arena.test.ts new file mode 100644 index 00000000..67049907 --- /dev/null +++ b/docs/start/io-game/arena.test.ts @@ -0,0 +1,73 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import { joinArena } from "./client.ts"; +import { startArena } from "./server.ts"; +import { arenaSize, type ArenaState } from "./schema.ts"; + +const until = async (cond: () => boolean) => { + for (let i = 0; i < 200 && !cond(); i++) await new Promise(r => setTimeout(r, 10)); + assert.ok(cond(), "condition not reached in 2s"); +}; + +test("two clients join one arena, see each other, and see each other move", async () => { + const arena = await startArena(); + const seenByA: ArenaState[] = []; + const seenByB: ArenaState[] = []; + const a = await joinArena(arena.odinUrl, "client-a", s => seenByA.push(structuredClone(s))); + const b = await joinArena(arena.odinUrl, "client-b", s => seenByB.push(structuredClone(s))); + try { + assert.notEqual(a.id, b.id); + await until(() => Object.keys(a.state()?.players ?? {}).length === 2); + await until(() => Object.keys(b.state()?.players ?? {}).length === 2); + + const before = structuredClone(b.state()!.players[a.id]!); + // Moves clamp at the arena edge, so pick a direction that always has room to move. + const dx = before.x > 0 ? -1 : 1; + await a.move(dx, 0); + await until(() => b.state()!.players[a.id]!.x !== before.x); + assert.equal(b.state()!.players[a.id]!.x, before.x + dx); + assert.deepEqual(a.state(), arena.state()); + assert.deepEqual(b.state(), arena.state()); + + await b.leave(); + await until(() => !(a.id !== b.id && b.id in a.state()!.players)); + assert.deepEqual(Object.keys(a.state()!.players), [a.id]); + } finally { + await a.leave().catch(() => undefined); + await arena.close(); + } +}); + +test("a player cannot walk out of the arena", async () => { + const arena = await startArena(); + const a = await joinArena(arena.odinUrl, "client-a", () => undefined); + try { + for (let i = 0; i <= arenaSize; i++) await a.move(-1, 0); + assert.equal(arena.state().players[a.id]!.x, 0); + } finally { + await a.leave(); + await arena.close(); + } +}); + +test("serve.ts bundles the browser client and serves the page", async () => { + const { serveArena } = await import("./serve.ts"); + const site = await serveArena(0); + try { + const html = await (await fetch(site.url)).text(); + assert.match(html, / { + const page = readFileSync(new URL("../io-game.md", import.meta.url), "utf8").replace(/\r\n/g, "\n"); + for (const file of ["schema.ts", "server.ts", "client.ts"]) { + const text = readFileSync(new URL(`./${file}`, import.meta.url), "utf8").replace(/\r\n/g, "\n"); + assert.ok(page.includes(text), `docs/start/io-game.md no longer shows ${file} verbatim`); + } +}); diff --git a/docs/start/io-game/browser.ts b/docs/start/io-game/browser.ts new file mode 100644 index 00000000..4120285f --- /dev/null +++ b/docs/start/io-game/browser.ts @@ -0,0 +1,17 @@ +import { joinArena } from "./client.ts"; +import { arenaSize, type ArenaState } from "./schema.ts"; + +const canvas = document.querySelector("canvas")!; +const ctx = canvas.getContext("2d")!; +const scale = canvas.width / arenaSize; + +const game = await joinArena(`ws://${location.host}/odin`, `browser-${crypto.randomUUID()}`, (state: ArenaState, me: string) => { + ctx.clearRect(0, 0, canvas.width, canvas.height); + for (const [id, p] of Object.entries(state.players)) { + ctx.fillStyle = id === me ? "#f5c542" : "#6aa9ff"; + ctx.fillRect(p.x * scale, p.y * scale, scale * 2, scale * 2); + } +}); +const keys: Record = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] }; +addEventListener("keydown", e => { const d = keys[e.key]; if (d) void game.move(...d); }); +addEventListener("pagehide", () => void game.leave()); diff --git a/docs/start/io-game/client.ts b/docs/start/io-game/client.ts new file mode 100644 index 00000000..8493e23c --- /dev/null +++ b/docs/start/io-game/client.ts @@ -0,0 +1,28 @@ +import { CultMeshBrowserClient, CultMeshBrowserOdinRendezvous, decodeCultNetOperationPayload, decodeCultNetPayload } from "cultmesh-browser"; +import { arenaDocument, arenaKey, authorityRuntimeId, verseId, type ArenaState } from "./schema.ts"; + +// Runs unchanged in a browser or in Node 22+: both have a global WebSocket. +export async function joinArena(odinUrl: string, runtimeId: string, onState: (state: ArenaState, myId: string) => void) { + const rendezvous = new CultMeshBrowserOdinRendezvous({ endpoints: [odinUrl], runtimeId: `${runtimeId}.discovery` }); + const mesh = await CultMeshBrowserClient.connect({ + verseId, authorityRuntimeId, runtimeId, rendezvous, + trust: { mode: "local-development" }, // loopback only; remote Verses need Odin roots + }); + let latest: ArenaState | undefined; // CultCache itself pulls in Node built-ins, so the browser client keeps the decoded document + const call = async (operation: string, payload: object) => + decodeCultNetOperationPayload<{ id?: string }>(await mesh.invoke({ serviceId: "io.arena", operation, payloadSchema: "io.request.v1", payload })); + + const lease = await mesh.leaseRawDocument({ schemaId: arenaDocument.schemaId, recordKey: arenaKey }); + const { id } = await call("join", {}); + lease.watch(record => { + if (!record) return; + latest = decodeCultNetPayload(record, arenaDocument); + onState(latest, id!); + }); + return { + id: id!, + move: (dx: number, dy: number) => call("move", { id, dx, dy }), + state: () => latest, + leave: async () => { await call("leave", { id }); lease.dispose(); await mesh.dispose(); }, + }; +} diff --git a/docs/start/io-game/index.html b/docs/start/io-game/index.html new file mode 100644 index 00000000..bcc77648 --- /dev/null +++ b/docs/start/io-game/index.html @@ -0,0 +1,6 @@ + + +arena + +

Arrow keys. Open this page in a second window.

+ diff --git a/docs/start/io-game/package-lock.json b/docs/start/io-game/package-lock.json new file mode 100644 index 00000000..48afe0a7 --- /dev/null +++ b/docs/start/io-game/package-lock.json @@ -0,0 +1,64 @@ +{ + "name": "cultlib-start-io-game", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "cultlib-start-io-game", + "dependencies": { + "ws": "^8.18.0" + }, + "devDependencies": { + "@types/ws": "^8.5.0" + } + }, + "node_modules/@types/node": { + "version": "26.6.6", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.6.tgz", + "integrity": "sha512-MzQj+8Kx3XI40OmUAE6Qoh58ZMNstNNMwn2nfd3qWsfe7nDNSqJNzes4njhz6AHwrSHOffSLwXU6yb474Q4aog==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.9.0" + } + }, + "node_modules/@types/ws": { + "version": "8.18.2", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.2.tgz", + "integrity": "sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/undici-types": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", + "dev": true, + "license": "MIT" + }, + "node_modules/ws": { + "version": "8.22.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.1.tgz", + "integrity": "sha512-oyL6OPYme/CmiFvNuGzeoxCJ4nOrZE2O1FwM9MWM1AsGri6nLXB2pg+lNA8aLeXMSav0J6eIVS1j6vUPzUtK9g==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/docs/start/io-game/package.json b/docs/start/io-game/package.json new file mode 100644 index 00000000..d8f1281f --- /dev/null +++ b/docs/start/io-game/package.json @@ -0,0 +1,9 @@ +{ + "name": "cultlib-start-io-game", + "private": true, + "type": "module", + "description": "docs/start recipe: a tiny two-client browser game over CultCache + CultMesh (TypeScript).", + "scripts": { "test": "node --test arena.test.ts" }, + "dependencies": { "ws": "^8.18.0" }, + "devDependencies": { "@types/ws": "^8.5.0" } +} diff --git a/docs/start/io-game/schema.ts b/docs/start/io-game/schema.ts new file mode 100644 index 00000000..d0c1a59b --- /dev/null +++ b/docs/start/io-game/schema.ts @@ -0,0 +1,16 @@ +import { defineDocumentType } from "@gamecult/cultcache-ts/document"; + +export interface Player { x: number; y: number } +export interface ArenaState { players: Record } + +// The one shared document: every player's position. The server owns it; clients lease it. +export const arenaDocument = defineDocumentType({ + type: "io.arena_state", + schemaId: "io.arena_state.v1", + schema: { parse: (value: unknown) => value as ArenaState }, +}); + +export const arenaKey = "arena:main"; +export const verseId = "io.arena"; +export const authorityRuntimeId = "io.arena-daemon"; +export const arenaSize = 100; diff --git a/docs/start/io-game/serve.ts b/docs/start/io-game/serve.ts new file mode 100644 index 00000000..82bf20be --- /dev/null +++ b/docs/start/io-game/serve.ts @@ -0,0 +1,17 @@ +import { readFileSync } from "node:fs"; +import { build } from "esbuild"; +import { startArena } from "./server.ts"; + +// Serves the page, the bundled browser client, and the arena's WebSocket on one port. +export async function serveArena(port = 8080, host = "127.0.0.1") { + const bundle = await build({ entryPoints: [new URL("./browser.ts", import.meta.url).pathname], bundle: true, format: "esm", target: "es2022", write: false }); + const html = readFileSync(new URL("./index.html", import.meta.url)); + const arena = await startArena(port, host, (req, res) => { + if (req.url === "/") res.writeHead(200, { "content-type": "text/html" }).end(html); + else if (req.url === "/browser.js") res.writeHead(200, { "content-type": "text/javascript" }).end(bundle.outputFiles[0]!.contents); + else res.writeHead(404).end(); + }); + return { url: arena.odinUrl.replace("ws:", "http:").replace("/odin", ""), close: arena.close }; +} + +if (import.meta.main) console.log(`arena at ${(await serveArena()).url}`); diff --git a/docs/start/io-game/server.ts b/docs/start/io-game/server.ts new file mode 100644 index 00000000..39a1a618 --- /dev/null +++ b/docs/start/io-game/server.ts @@ -0,0 +1,100 @@ +import { createServer, type RequestListener, type Server } from "node:http"; +import { CultCache } from "@gamecult/cultcache-ts"; +import { defineDocumentRegistry } from "@gamecult/cultcache-ts/document"; +import { decodeWireValue, encodeWireValue } from "cultnet-ts/wire"; +import { encodeCultNetMessageForWire, parseCultNetMessage, type CultNetMessage } from "cultnet-ts/contracts"; +import { WebSocketServer, type WebSocket } from "ws"; +import { arenaDocument, arenaKey, arenaSize, authorityRuntimeId, verseId, type ArenaState } from "./schema.ts"; + +const bytesToBase64 = (b: Uint8Array) => Buffer.from(b).toString("base64"); +const base64ToBytes = (s: string) => new Uint8Array(Buffer.from(s, "base64")); + +// One process plays both CultMesh roles: Odin (/odin tells clients where the authority is) and the authority (/mesh). +export async function startArena(port = 0, host = "127.0.0.1", onRequest?: RequestListener) { + const cache = CultCache.builder().withRegistry(defineDocumentRegistry(arenaDocument)).build(); + await cache.put(arenaDocument, arenaKey, { players: {} }); + const http: Server = createServer(onRequest ?? ((_, res) => { res.writeHead(404).end(); })); + await new Promise(resolve => http.listen(port, host, resolve)); + const endpoint = `ws://${host}:${(http.address() as { port: number }).port}`; + + const subscribers = new Map(); + const send = (ws: WebSocket, m: CultNetMessage) => ws.send(encodeWireValue(encodeCultNetMessageForWire(m))); + const record = () => ({ + schemaId: arenaDocument.schemaId, schemaName: arenaDocument.type, schemaVersion: arenaDocument.schemaId, + schemaContentHash: arenaDocument.schemaId, recordKey: arenaKey, storedAt: new Date().toISOString(), + payloadEncoding: "messagepack" as const, + payload: encodeWireValue(cache.getRequired(arenaDocument, arenaKey)) as never, + sourceRuntimeId: authorityRuntimeId, + }); + const publish = async (next: ArenaState) => { + await cache.put(arenaDocument, arenaKey, next); + for (const [ws, ids] of subscribers) for (const subscriptionId of ids) { + send(ws, { schemaVersion: "cultnet.database_change_raw.v0", messageId: crypto.randomUUID(), subscriptionId, changeKind: "updated", document: record() }); + } + }; + + let nextId = 0; + const operations: Record Promise> = { + join: async () => { + const id = `p${++nextId}`; + const state = cache.getRequired(arenaDocument, arenaKey); + await publish({ players: { ...state.players, [id]: { x: Math.floor(Math.random() * arenaSize), y: Math.floor(Math.random() * arenaSize) } } }); + return { id }; + }, + move: async ({ id, dx, dy }: { id: string; dx: number; dy: number }) => { + const state = cache.getRequired(arenaDocument, arenaKey); + const me = state.players[id]; + if (!me) return { ok: false }; + const clamp = (v: number) => Math.max(0, Math.min(arenaSize, v)); + await publish({ players: { ...state.players, [id]: { x: clamp(me.x + Math.sign(dx)), y: clamp(me.y + Math.sign(dy)) } } }); + return { ok: true }; + }, + leave: async ({ id }: { id: string }) => { + const { [id]: _gone, ...rest } = cache.getRequired(arenaDocument, arenaKey).players; + await publish({ players: rest }); + return { ok: true }; + }, + }; + + const wss = new WebSocketServer({ server: http }); + wss.on("connection", (ws, req) => { + const odin = req.url === "/odin"; + ws.on("close", () => subscribers.delete(ws)); + ws.on("message", async raw => { + const m = parseCultNetMessage(decodeWireValue(new Uint8Array(raw as Buffer))); + if (odin && m.schemaVersion === "cultmesh.verse_catalog_request.v0") { + send(ws, { + schemaVersion: "cultmesh.verse_catalog_response.v0", messageId: m.messageId, + verses: [{ + verseId, displayName: "Arena", authorityModel: "ServerAuthoritative", + compatibility: { transportVersion: "cultmesh.v1", rulesHash: "arena-v1", compatibleVerseIds: [], requiredPluginIds: [], optionalPluginIds: [] }, + discoveryEndpoints: [`${endpoint}/mesh`], authorityRuntimeIds: [authorityRuntimeId], + authorityRoutes: [{ authorityRuntimeId, endpoint: `${endpoint}/mesh`, protocolIds: ["cultmesh.documents.v1"], priority: 0, generation: "arena-1" }], + }], + }); + } else if (m.schemaVersion === "cultmesh.session_open.v2") { + send(ws, { schemaVersion: "cultmesh.session_accepted.v2", messageId: m.messageId, accepted: true, verseId: m.verseId, authorityRuntimeId, + protocolId: m.protocolId, routeGeneration: m.routeGeneration, clientNonce: m.clientNonce, providerKeyId: "", providerSignature: "" }); + } else if (m.schemaVersion === "cultnet.database_subscribe.v0") { + subscribers.set(ws, [...(subscribers.get(ws) ?? []), m.subscriptionId]); + send(ws, { schemaVersion: "cultnet.snapshot_response_raw.v0", messageId: m.messageId, documents: [record()] }); + } else if (m.schemaVersion === "cultnet.database_unsubscribe.v0") { + subscribers.set(ws, (subscribers.get(ws) ?? []).filter(id => id !== m.subscriptionId)); + } else if (m.schemaVersion === "cultnet.operation_request.v0") { + const handler = operations[m.operation]; + const receipt = handler ? await handler(decodeWireValue(base64ToBytes(m.payload))) : undefined; + send(ws, { + schemaVersion: "cultnet.operation_response.v0", messageId: m.messageId, serviceId: m.serviceId, operation: m.operation, + status: handler ? "accepted" : "invalid", payloadSchema: "io.receipt.v1", payloadEncoding: "messagepack-base64", + payload: bytesToBase64(encodeWireValue(receipt ?? { ok: false })), sourceRuntimeId: authorityRuntimeId, + }); + } + }); + }); + + return { + odinUrl: `${endpoint}/odin`, + state: () => cache.getRequired(arenaDocument, arenaKey), + close: async () => { for (const c of wss.clients) c.terminate(); wss.close(); await new Promise(r => http.close(r)); }, + }; +} -- 2.47.3