diff --git a/.changeset/better-bikes-repair.md b/.changeset/better-bikes-repair.md new file mode 100644 index 0000000..c7472be --- /dev/null +++ b/.changeset/better-bikes-repair.md @@ -0,0 +1,5 @@ +--- +"@jolly-pixel/voxel.renderer": major +--- + +Implement new Network API to synchronize world between multiple clients diff --git a/packages/voxel-renderer/docs/Hooks.md b/packages/voxel-renderer/docs/Hooks.md index 8c3877f..82882a0 100644 --- a/packages/voxel-renderer/docs/Hooks.md +++ b/packages/voxel-renderer/docs/Hooks.md @@ -24,25 +24,53 @@ const vr = new VoxelRenderer({ }); ``` +You can also set (or replace) the hook after construction: + +```ts +vr.onLayerUpdated = (event) => { /* ... */ }; +// Clear the hook: +vr.onLayerUpdated = undefined; +``` + ## Event reference `VoxelLayerHookEvent` is a discriminated union keyed on `action`. Narrowing on `action` gives you a precise `metadata` type with no casting required. -| `action` | `metadata` shape | -|---|---| -| `"added"` | `{ options: VoxelLayerConfigurableOptions }` | -| `"removed"` | `{}` | -| `"updated"` | `{ options: Partial }` | -| `"offset-updated"` | `{ offset: VoxelCoord }` or `{ delta: VoxelCoord }` | -| `"voxel-set"` | `{ position, blockId, rotation, flipX, flipZ, flipY }` | -| `"voxel-removed"` | `{ position: Vector3Like }` | -| `"reordered"` | `{ direction: "up" \| "down" }` | -| `"object-layer-added"` | `{}` | -| `"object-layer-removed"` | `{}` | -| `"object-layer-updated"` | `{ patch: { visible?: boolean } }` | -| `"object-added"` | `{ objectId: string }` | -| `"object-removed"` | `{ objectId: string }` | -| `"object-updated"` | `{ objectId: string; patch: Partial }` | +| `action` | `metadata` shape | Notes | +|---|---|---| +| `"added"` | `{ options: VoxelLayerConfigurableOptions }` | | +| `"removed"` | `{}` | | +| `"updated"` | `{ options: Partial }` | | +| `"offset-updated"` | `{ offset: VoxelCoord }` or `{ delta: VoxelCoord }` | | +| `"voxel-set"` | `{ position, blockId, rotation, flipX, flipZ, flipY }` | | +| `"voxel-removed"` | `{ position: Vector3Like }` | | +| `"voxels-set"` | `{ entries: VoxelSetOptions[] }` | Bulk placement | +| `"voxels-removed"` | `{ entries: VoxelRemoveOptions[] }` | Bulk removal | +| `"reordered"` | `{ direction: "up" \| "down" }` | | +| `"object-layer-added"` | `{}` | | +| `"object-layer-removed"` | `{}` | | +| `"object-layer-updated"` | `{ patch: { visible?: boolean } }` | | +| `"object-added"` | `{ object: VoxelObjectJSON }` | Full object, not just ID | +| `"object-removed"` | `{ objectId: string }` | | +| `"object-updated"` | `{ objectId: string; patch: Partial }` | | `VoxelLayerHookAction` is a convenience alias for `VoxelLayerHookEvent["action"]`. + +## Breaking change: `"object-added"` metadata + +Prior to the network sync layer, the `"object-added"` event carried `{ objectId: string }`. +It now carries `{ object: VoxelObjectJSON }` so remote commands can fully reconstruct the +object without an extra lookup. Update existing consumers: + +```ts +// Before +if (event.action === "object-added") { + console.log(event.metadata.objectId); +} + +// After +if (event.action === "object-added") { + console.log(event.metadata.object.id); // same value, richer payload +} +``` diff --git a/packages/voxel-renderer/docs/Network.md b/packages/voxel-renderer/docs/Network.md new file mode 100644 index 0000000..54a0be1 --- /dev/null +++ b/packages/voxel-renderer/docs/Network.md @@ -0,0 +1,252 @@ +# Network Sync Layer + +The network sync layer adds **transport-agnostic, server-authoritative multiplayer** on top of the existing voxel renderer. It lets multiple clients share the same voxel world in real time without coupling the renderer to any specific transport technology. + +## Architecture overview + +``` +┌─────────────┐ local mutation ┌──────────────────┐ sendCommand ┌─────────────┐ +│VoxelRenderer│──────────────────▶│ VoxelSyncClient │────────────────▶│ Transport │ +│ (Three.js) │ │ │◀────────────────│ (WebSocket, │ +│ │◀──applyRemote──── │ │ onCommand │ WebRTC, …) │ +└─────────────┘ └──────────────────┘ └──────┬──────┘ + │ wire + ▼ + ┌─────────────────┐ + │ VoxelSyncServer │ + │ (headless) │ + │ VoxelWorld │ + └─────────────────┘ +``` + +**Flow:** +1. A local mutation (e.g. `setVoxel`) fires the `onLayerUpdated` hook. +2. `VoxelSyncClient` intercepts the hook, stamps the command with `clientId / seq / timestamp`, and calls `transport.sendCommand(cmd)`. +3. The transport sends the command over the wire to `VoxelSyncServer.receive()`. +4. The server validates the command (LWW conflict resolution), applies it to its authoritative `VoxelWorld`, and broadcasts it to all connected clients. +5. Each client's transport calls `onCommand(cmd)`, which `VoxelSyncClient` routes to `renderer.applyRemoteCommand(cmd)`. +6. `applyRemoteCommand` sets an internal flag so that the resulting hook event is **not** re-emitted — preventing infinite echo loops. + +## VoxelTransport interface + +You must supply a concrete transport implementation. The interface is minimal: + +```ts +interface VoxelTransport { + readonly localClientId: string; + sendCommand(cmd: VoxelNetworkCommand): void; + requestSnapshot(): void; + onCommand: ((cmd: VoxelNetworkCommand) => void) | null; + onSnapshot: ((snapshot: VoxelWorldJSON) => void) | null; + onPeerJoined: ((peerId: string) => void) | null; + onPeerLeft: ((peerId: string) => void) | null; +} +``` + +### WebSocket example stub + +```ts +import type { VoxelTransport, VoxelNetworkCommand } from "@jolly-pixel/voxel.renderer"; +import type { VoxelWorldJSON } from "@jolly-pixel/voxel.renderer"; + +class WebSocketTransport implements VoxelTransport { + readonly localClientId = crypto.randomUUID(); + onCommand: ((cmd: VoxelNetworkCommand) => void) | null = null; + onSnapshot: ((snapshot: VoxelWorldJSON) => void) | null = null; + onPeerJoined: ((peerId: string) => void) | null = null; + onPeerLeft: ((peerId: string) => void) | null = null; + + constructor(private ws: WebSocket) { + ws.addEventListener("message", (ev) => { + const msg = JSON.parse(ev.data as string); + switch (msg.type) { + case "snapshot": this.onSnapshot?.(msg.data); break; + case "command": this.onCommand?.(msg.data); break; + case "peer-joined": this.onPeerJoined?.(msg.peerId); break; + case "peer-left": this.onPeerLeft?.(msg.peerId); break; + } + }); + } + + sendCommand(cmd: VoxelNetworkCommand): void { + this.ws.send(JSON.stringify({ type: "command", data: cmd })); + } + + requestSnapshot(): void { + this.ws.send(JSON.stringify({ type: "snapshot-request" })); + } +} +``` + +## VoxelSyncClient + +### Setup + +```ts +import { + VoxelSyncClient, + type VoxelSyncClientOptions +} from "@jolly-pixel/voxel.renderer"; + +const client = new VoxelSyncClient({ + renderer: vr, // pre-constructed VoxelRenderer + transport: myTransport +}); +``` + +The client: +- Replaces `renderer.onLayerUpdated` with its own interceptor. +- Wires `transport.onCommand` and `transport.onSnapshot`. + +### Lifecycle + +```ts +// When the session ends: +client.destroy(); +``` + +`destroy()` clears `renderer.onLayerUpdated` and the transport callbacks so the renderer +reverts to standalone mode. + +### Options + +| Option | Type | Description | +|--------|------|-------------| +| `renderer` | `VoxelRenderer` | The local renderer to synchronize. | +| `transport` | `VoxelTransport` | Your transport implementation. | + +## VoxelSyncServer + +The server runs **headlessly** (no Three.js dependency) and can be used in Node.js, Deno, or Bun. + +### Setup + +```ts +import { VoxelSyncServer, type ClientHandle } from "@jolly-pixel/voxel.renderer"; + +const server = new VoxelSyncServer(); +// Optionally pass an existing world: +// const server = new VoxelSyncServer({ world: existingVoxelWorld }); +``` + +### WebSocket server example + +```ts +import { WebSocketServer } from "ws"; + +const wss = new WebSocketServer({ port: 3000 }); + +wss.on("connection", (ws) => { + const client: ClientHandle = { + id: crypto.randomUUID(), + send: (data) => ws.send(JSON.stringify(data)) + }; + + server.connect(client); // sends snapshot to new client + + ws.on("message", (raw) => { + const cmd = JSON.parse(raw.toString()); + server.receive(cmd); // validate → apply → broadcast + }); + + ws.on("close", () => server.disconnect(client.id)); +}); +``` + +### API + +| Method | Description | +|--------|-------------| +| `connect(client)` | Registers client; sends current snapshot; notifies peers. | +| `disconnect(clientId)` | Removes client; notifies remaining peers. | +| `receive(cmd)` | Validates, applies, and broadcasts a command. | +| `snapshot()` | Returns the current world as `VoxelWorldJSON`. | +| `world` | The authoritative `VoxelWorld` instance. | + +### Options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `world` | `VoxelWorld` | new world | Existing world to use as authoritative state. | +| `chunkSize` | `number` | `16` | Chunk size when creating a new world. | +| `conflictResolver` | `ConflictResolver` | `LastWriteWinsResolver` | Custom conflict strategy. | + +## VoxelNetworkCommand — wire format + +A `VoxelNetworkCommand` is a `VoxelLayerHookEvent` extended with routing metadata: + +```ts +type VoxelNetworkCommand = VoxelLayerHookEvent & { + clientId: string; // originating client ID + seq: number; // monotonically increasing per client + timestamp: number; // Unix ms (Date.now()) at time of mutation +}; +``` + +Commands are plain JSON-serializable objects — no special framing required. + +## ConflictResolver + +### Default: LastWriteWinsResolver + +The default resolver uses **timestamp** to determine which command wins at a given voxel +position. On a tie, the lexicographically greater `clientId` wins (deterministic without +coordination). + +```ts +import { LastWriteWinsResolver } from "@jolly-pixel/voxel.renderer"; + +const server = new VoxelSyncServer({ + conflictResolver: new LastWriteWinsResolver() // default, no need to pass explicitly +}); +``` + +### Custom resolver + +Implement `ConflictResolver` for custom strategies (e.g. first-write-wins, priority by +role, etc.): + +```ts +import type { ConflictResolver, ConflictContext } from "@jolly-pixel/voxel.renderer"; + +class FirstWriteWinsResolver implements ConflictResolver { + resolve({ existing }: ConflictContext): "accept" | "reject" { + // Accept only if no prior command exists at this position + return existing ? "reject" : "accept"; + } +} + +const server = new VoxelSyncServer({ conflictResolver: new FirstWriteWinsResolver() }); +``` + +> **Note:** Conflict resolution only applies to per-position voxel operations (`"voxel-set"`, +> `"voxel-removed"`). Structural layer operations (`"added"`, `"removed"`, `"reordered"`, etc.) +> are always accepted. + +## VoxelCommandApplier — headless usage + +`applyCommandToWorld` lets you replay hook events against a bare `VoxelWorld` without a +renderer. Useful for server-side logic, unit tests, or offline editing tools. + +```ts +import { VoxelWorld, applyCommandToWorld } from "@jolly-pixel/voxel.renderer"; + +const world = new VoxelWorld(16); +applyCommandToWorld(world, { + action: "added", + layerName: "Ground", + metadata: { options: {} } +}); +applyCommandToWorld(world, { + action: "voxel-set", + layerName: "Ground", + metadata: { + position: { x: 0, y: 0, z: 0 }, + blockId: 1, + rotation: 0, + flipX: false, + flipZ: false, + flipY: false + } +}); +``` diff --git a/packages/voxel-renderer/src/VoxelRenderer.ts b/packages/voxel-renderer/src/VoxelRenderer.ts index f5ad7ef..bb61892 100644 --- a/packages/voxel-renderer/src/VoxelRenderer.ts +++ b/packages/voxel-renderer/src/VoxelRenderer.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-lines */ // Import Third-party Dependencies import * as THREE from "three"; import { @@ -46,8 +47,12 @@ import type { VoxelEntry, VoxelCoord } from "./world/types.ts"; import { packTransform, type FACE } from "./utils/math.ts"; import { FACE_OFFSETS } from "./mesh/math.ts"; import type { - VoxelLayerHookListener + VoxelLayerHookListener, + VoxelLayerHookEvent } from "./hooks.ts"; +import type { VoxelSetOptions, VoxelRemoveOptions } from "./types.ts"; + +export type { VoxelSetOptions, VoxelRemoveOptions }; type MaterialCustomizerFn = ( material: THREE.MeshLambertMaterial | THREE.MeshStandardMaterial, @@ -65,23 +70,6 @@ export const VoxelRotation = { CW90: 3 } as const; -export interface VoxelSetOptions { - position: THREE.Vector3Like; - blockId: number; - /** Y-axis rotation. Use the `VoxelRotation` constants. Default: `VoxelRotation.None` */ - rotation?: typeof VoxelRotation[keyof typeof VoxelRotation]; - /** Mirror the block around x = 0.5. Default: false */ - flipX?: boolean; - /** Mirror the block around z = 0.5. Default: false */ - flipZ?: boolean; - /** Mirror the block around y = 0.5. Default: false */ - flipY?: boolean; -} - -export interface VoxelRemoveOptions { - position: THREE.Vector3Like; -} - export interface VoxelRendererOptions { /** * @default 16 @@ -187,6 +175,12 @@ export class VoxelRenderer extends ActorComponent { #logger: Systems.Logger; #onLayerUpdated?: VoxelLayerHookListener; + /** + * When true, hook events are suppressed so that remote commands applied via + * `applyRemoteCommand` do not re-broadcast to the transport layer. + */ + #isApplyingRemote = false; + constructor( actor: Actor, options: VoxelRendererOptions = {} @@ -303,6 +297,127 @@ export class VoxelRenderer extends ActorComponent { super.destroy(); } + // --- Hook management --- // + + /** + * Replace the hook listener after construction. Setting to `undefined` disables hooks. + * Used by `VoxelSyncClient` to inject itself. + */ + set onLayerUpdated(fn: VoxelLayerHookListener | undefined) { + this.#onLayerUpdated = fn; + } + + /** + * Emits a hook event unless a remote command is currently being applied. + */ + #emitHook(event: VoxelLayerHookEvent): void { + if (this.#isApplyingRemote) { + return; + } + this.#onLayerUpdated?.(event); + } + + /** + * Dispatches a hook event to the corresponding local mutation method. + * Called from `applyRemoteCommand` with `#isApplyingRemote = true` so + * the mutation does not re-fire the hook. + */ + #dispatchCommand(event: VoxelLayerHookEvent): void { + switch (event.action) { + case "added": + this.addLayer(event.layerName, event.metadata.options); + break; + + case "removed": + this.removeLayer(event.layerName); + break; + + case "updated": + this.updateLayer(event.layerName, event.metadata.options); + break; + + case "offset-updated": + if ("offset" in event.metadata) { + this.setLayerOffset(event.layerName, event.metadata.offset); + } + else { + this.translateLayer(event.layerName, event.metadata.delta); + } + break; + + case "voxel-set": { + const { position, blockId, rotation, flipX, flipZ, flipY } = event.metadata; + this.setVoxel(event.layerName, { + position, + blockId, + rotation: rotation as 0 | 1 | 2 | 3, + flipX, + flipZ, + flipY + }); + break; + } + + case "voxel-removed": + this.removeVoxel(event.layerName, { position: event.metadata.position }); + break; + + case "voxels-set": + this.setVoxelBulk(event.layerName, event.metadata.entries); + break; + + case "voxels-removed": + this.removeVoxelBulk(event.layerName, event.metadata.entries); + break; + + case "reordered": + this.moveLayer(event.layerName, event.metadata.direction); + break; + + case "object-layer-added": + this.addObjectLayer(event.layerName); + break; + + case "object-layer-removed": + this.removeObjectLayer(event.layerName); + break; + + case "object-layer-updated": + this.updateObjectLayer(event.layerName, event.metadata.patch); + break; + + case "object-added": + this.addObject(event.layerName, event.metadata.object); + break; + + case "object-removed": + this.removeObject(event.layerName, event.metadata.objectId); + break; + + case "object-updated": + this.updateObject( + event.layerName, + event.metadata.objectId, + event.metadata.patch + ); + break; + } + } + + /** + * Applies a remote command (received from a network peer) to the local world + * without re-emitting the hook, preventing echo loops. + */ + applyRemoteCommand(cmd: VoxelLayerHookEvent): void { + this.#isApplyingRemote = true; + try { + this.#dispatchCommand(cmd); + } + finally { + this.#isApplyingRemote = false; + } + } + // --- API --- // /** @@ -328,7 +443,7 @@ export class VoxelRenderer extends ActorComponent { position, { blockId, transform } ); - this.#onLayerUpdated?.({ + this.#emitHook({ action: "voxel-set", layerName, metadata: { position, blockId, rotation, flipX, flipZ, flipY } @@ -340,7 +455,7 @@ export class VoxelRenderer extends ActorComponent { options: VoxelRemoveOptions ): void { this.world.removeVoxelAt(layerName, options.position); - this.#onLayerUpdated?.({ + this.#emitHook({ action: "voxel-removed", layerName, metadata: { position: options.position } @@ -365,6 +480,11 @@ export class VoxelRenderer extends ActorComponent { { blockId, transform: packTransform(rotation, flipX, flipZ, flipY) } ); } + this.#emitHook({ + action: "voxels-set", + layerName, + metadata: { entries } + }); } removeVoxelBulk( @@ -374,6 +494,11 @@ export class VoxelRenderer extends ActorComponent { for (const { position } of entries) { this.world.removeVoxelAt(layerName, position); } + this.#emitHook({ + action: "voxels-removed", + layerName, + metadata: { entries } + }); } getVoxel(position: THREE.Vector3Like): VoxelEntry | undefined; @@ -423,7 +548,7 @@ export class VoxelRenderer extends ActorComponent { options: VoxelLayerConfigurableOptions = {} ): VoxelLayer { const layer = this.world.addLayer(name, options); - this.#onLayerUpdated?.({ + this.#emitHook({ action: "added", layerName: name, metadata: { options } @@ -438,7 +563,7 @@ export class VoxelRenderer extends ActorComponent { ): boolean { const result = this.world.updateLayer(name, options); if (result) { - this.#onLayerUpdated?.({ + this.#emitHook({ action: "updated", layerName: name, metadata: { options } @@ -453,7 +578,7 @@ export class VoxelRenderer extends ActorComponent { ): boolean { const result = this.world.removeLayer(name); if (result) { - this.#onLayerUpdated?.({ + this.#emitHook({ action: "removed", layerName: name, metadata: {} @@ -468,7 +593,7 @@ export class VoxelRenderer extends ActorComponent { offset: VoxelCoord ): void { this.world.setLayerOffset(name, offset); - this.#onLayerUpdated?.({ + this.#emitHook({ action: "offset-updated", layerName: name, metadata: { offset } @@ -480,7 +605,7 @@ export class VoxelRenderer extends ActorComponent { delta: VoxelCoord ): void { this.world.translateLayer(name, delta); - this.#onLayerUpdated?.({ + this.#emitHook({ action: "offset-updated", layerName: name, metadata: { delta } @@ -493,7 +618,7 @@ export class VoxelRenderer extends ActorComponent { ): void { this.world.moveLayer(name, direction); this.markAllChunksDirty("moveLayer"); - this.#onLayerUpdated?.({ + this.#emitHook({ action: "reordered", layerName: name, metadata: { direction } @@ -518,7 +643,7 @@ export class VoxelRenderer extends ActorComponent { options?: Partial> ): VoxelObjectLayerJSON { const layer = this.world.addObjectLayer(name, options); - this.#onLayerUpdated?.({ + this.#emitHook({ action: "object-layer-added", layerName: name, metadata: {} @@ -532,7 +657,7 @@ export class VoxelRenderer extends ActorComponent { ): boolean { const result = this.world.removeObjectLayer(name); if (result) { - this.#onLayerUpdated?.({ + this.#emitHook({ action: "object-layer-removed", layerName: name, metadata: {} @@ -558,7 +683,7 @@ export class VoxelRenderer extends ActorComponent { ): boolean { const result = this.world.updateObjectLayer(name, patch); if (result) { - this.#onLayerUpdated?.({ + this.#emitHook({ action: "object-layer-updated", layerName: name, metadata: { patch } @@ -574,10 +699,10 @@ export class VoxelRenderer extends ActorComponent { ): boolean { const result = this.world.addObjectToLayer(layerName, object); if (result) { - this.#onLayerUpdated?.({ + this.#emitHook({ action: "object-added", layerName, - metadata: { objectId: object.id } + metadata: { object } }); } @@ -590,7 +715,7 @@ export class VoxelRenderer extends ActorComponent { ): boolean { const result = this.world.removeObjectFromLayer(layerName, objectId); if (result) { - this.#onLayerUpdated?.({ + this.#emitHook({ action: "object-removed", layerName, metadata: { objectId } @@ -607,7 +732,7 @@ export class VoxelRenderer extends ActorComponent { ): boolean { const result = this.world.updateObjectInLayer(layerName, objectId, patch); if (result) { - this.#onLayerUpdated?.({ + this.#emitHook({ action: "object-updated", layerName, metadata: { objectId, patch } diff --git a/packages/voxel-renderer/src/hooks.ts b/packages/voxel-renderer/src/hooks.ts index ca5b670..72405e7 100644 --- a/packages/voxel-renderer/src/hooks.ts +++ b/packages/voxel-renderer/src/hooks.ts @@ -8,6 +8,7 @@ import type { VoxelObjectLayerJSON, VoxelObjectJSON } from "./serialization/VoxelSerializer.ts"; +import type { VoxelSetOptions, VoxelRemoveOptions } from "./types.ts"; export type VoxelLayerHookEvent = | { @@ -53,6 +54,20 @@ export type VoxelLayerHookEvent = position: Vector3Like; }; } + | { + action: "voxels-set"; + layerName: string; + metadata: { + entries: VoxelSetOptions[]; + }; + } + | { + action: "voxels-removed"; + layerName: string; + metadata: { + entries: VoxelRemoveOptions[]; + }; + } | { action: "reordered"; layerName: string; @@ -81,7 +96,7 @@ export type VoxelLayerHookEvent = action: "object-added"; layerName: string; metadata: { - objectId: string; + object: VoxelObjectJSON; }; } | { diff --git a/packages/voxel-renderer/src/index.ts b/packages/voxel-renderer/src/index.ts index 73b877c..342568d 100644 --- a/packages/voxel-renderer/src/index.ts +++ b/packages/voxel-renderer/src/index.ts @@ -84,6 +84,9 @@ export type { // Convertor export * from "./convertor/index.ts"; +// Network +export * from "./network/index.ts"; + // Math export { FACE as Face diff --git a/packages/voxel-renderer/src/network/ConflictResolver.ts b/packages/voxel-renderer/src/network/ConflictResolver.ts new file mode 100644 index 0000000..ee3c462 --- /dev/null +++ b/packages/voxel-renderer/src/network/ConflictResolver.ts @@ -0,0 +1,44 @@ +// Import Internal Dependencies +import type { VoxelNetworkCommand } from "./types.ts"; + +export interface ConflictContext { + /** The incoming command to evaluate. */ + incoming: VoxelNetworkCommand; + /** + * The last accepted command at the same position, if any. + * `undefined` means no prior command exists → always accept. + */ + existing: VoxelNetworkCommand | undefined; +} + +/** + * Determines whether an incoming command should be accepted or rejected + * given the last known command at the same world position. + */ +export interface ConflictResolver { + resolve(ctx: ConflictContext): "accept" | "reject"; +} + +/** + * Last-Write-Wins resolver: the command with the higher `timestamp` wins. + * On a timestamp tie, the lexicographically greater `clientId` wins, + * giving a deterministic total order without coordination. + */ +export class LastWriteWinsResolver implements ConflictResolver { + resolve({ incoming, existing }: ConflictContext): "accept" | "reject" { + if (!existing) { + return "accept"; + } + + if (incoming.timestamp > existing.timestamp) { + return "accept"; + } + + if (incoming.timestamp < existing.timestamp) { + return "reject"; + } + + // Tie-break: lexicographically greater clientId wins. + return incoming.clientId >= existing.clientId ? "accept" : "reject"; + } +} diff --git a/packages/voxel-renderer/src/network/VoxelCommandApplier.ts b/packages/voxel-renderer/src/network/VoxelCommandApplier.ts new file mode 100644 index 0000000..d8026c8 --- /dev/null +++ b/packages/voxel-renderer/src/network/VoxelCommandApplier.ts @@ -0,0 +1,116 @@ +// Import Internal Dependencies +import type { VoxelLayerHookEvent } from "../hooks.ts"; +import type { VoxelWorld } from "../world/VoxelWorld.ts"; +import { packTransform } from "../utils/math.ts"; + +/** + * Applies a single hook event to a headless `VoxelWorld` instance. + * + * Used by `VoxelSyncServer` (Node.js, no Three.js) and can be used standalone + * for testing replay logic without a renderer. + * + * @example + * ```ts + * const world = new VoxelWorld(16); + * applyCommandToWorld(world, { + * action: "added", + * layerName: "Ground", + * metadata: { options: {} } + * }); + * ``` + */ +export function applyCommandToWorld( + world: VoxelWorld, + cmd: VoxelLayerHookEvent +): void { + switch (cmd.action) { + case "added": + world.addLayer(cmd.layerName, cmd.metadata.options); + break; + + case "removed": + world.removeLayer(cmd.layerName); + break; + + case "updated": + world.updateLayer(cmd.layerName, cmd.metadata.options); + break; + + case "offset-updated": + if ("offset" in cmd.metadata) { + world.setLayerOffset(cmd.layerName, cmd.metadata.offset); + } + else { + world.translateLayer(cmd.layerName, cmd.metadata.delta); + } + break; + + case "voxel-set": { + const { position, blockId, rotation, flipX, flipZ, flipY } = cmd.metadata; + world.setVoxelAt(cmd.layerName, position, { + blockId, + transform: packTransform(rotation as 0 | 1 | 2 | 3, flipX, flipZ, flipY) + }); + break; + } + + case "voxel-removed": + world.removeVoxelAt(cmd.layerName, cmd.metadata.position); + break; + + case "voxels-set": + for (const entry of cmd.metadata.entries) { + const { + position, + blockId, + rotation = 0, + flipX = false, + flipZ = false, + flipY = false + } = entry; + world.setVoxelAt(cmd.layerName, position, { + blockId, + transform: packTransform(rotation, flipX, flipZ, flipY) + }); + } + break; + + case "voxels-removed": + for (const entry of cmd.metadata.entries) { + world.removeVoxelAt(cmd.layerName, entry.position); + } + break; + + case "reordered": + world.moveLayer(cmd.layerName, cmd.metadata.direction); + break; + + case "object-layer-added": + world.addObjectLayer(cmd.layerName); + break; + + case "object-layer-removed": + world.removeObjectLayer(cmd.layerName); + break; + + case "object-layer-updated": + world.updateObjectLayer(cmd.layerName, cmd.metadata.patch); + break; + + case "object-added": + world.addObjectToLayer(cmd.layerName, cmd.metadata.object); + break; + + case "object-removed": + world.removeObjectFromLayer(cmd.layerName, cmd.metadata.objectId); + break; + + case "object-updated": + world.updateObjectInLayer( + cmd.layerName, + cmd.metadata.objectId, + cmd.metadata.patch + ); + break; + } +} diff --git a/packages/voxel-renderer/src/network/VoxelSyncClient.ts b/packages/voxel-renderer/src/network/VoxelSyncClient.ts new file mode 100644 index 0000000..abb8e76 --- /dev/null +++ b/packages/voxel-renderer/src/network/VoxelSyncClient.ts @@ -0,0 +1,81 @@ +// Import Internal Dependencies +import type { VoxelRenderer } from "../VoxelRenderer.ts"; +import type { VoxelLayerHookEvent } from "../hooks.ts"; +import type { VoxelWorldJSON } from "../serialization/VoxelSerializer.ts"; +import type { VoxelTransport } from "./VoxelTransport.ts"; +import type { VoxelNetworkCommand } from "./types.ts"; + +export interface VoxelSyncClientOptions { + /** + * The local `VoxelRenderer` instance to synchronize. + * The client will replace its `onLayerUpdated` hook. + */ + renderer: VoxelRenderer; + /** Transport implementation (WebSocket, WebRTC, etc.). */ + transport: VoxelTransport; +} + +/** + * Client-side network orchestrator. + * + * Wires a `VoxelRenderer` to a `VoxelTransport` so that: + * - Local mutations are stamped and forwarded to the server. + * - Remote commands received from the server are applied without re-emitting hooks. + * - World snapshots from the server are loaded into the renderer. + * + * @example + * ```ts + * const client = new VoxelSyncClient({ renderer: vr, transport: myTransport }); + * // …later… + * client.destroy(); + * ``` + */ +export class VoxelSyncClient { + #renderer: VoxelRenderer; + #transport: VoxelTransport; + #seq = 0; + + constructor( + options: VoxelSyncClientOptions + ) { + this.#renderer = options.renderer; + this.#transport = options.transport; + + // Intercept local mutations and forward them to the transport. + this.#renderer.onLayerUpdated = (event) => this.#handleLocal(event); + + // Apply incoming commands from remote peers without re-emitting hooks. + this.#transport.onCommand = (cmd) => { + if (cmd.clientId !== this.#transport.localClientId) { + this.#renderer.applyRemoteCommand(cmd); + } + }; + + // Load world snapshots received from the server. + this.#transport.onSnapshot = (snapshot: VoxelWorldJSON) => { + void this.#renderer.load(snapshot); + }; + } + + #handleLocal( + event: VoxelLayerHookEvent + ): void { + const cmd = { + ...event, + clientId: this.#transport.localClientId, + seq: ++this.#seq, + timestamp: Date.now() + } as VoxelNetworkCommand; + + this.#transport.sendCommand(cmd); + } + + /** + * Detaches from the renderer and transport. Call when the session ends. + */ + destroy(): void { + this.#renderer.onLayerUpdated = undefined; + this.#transport.onCommand = null; + this.#transport.onSnapshot = null; + } +} diff --git a/packages/voxel-renderer/src/network/VoxelSyncServer.ts b/packages/voxel-renderer/src/network/VoxelSyncServer.ts new file mode 100644 index 0000000..11a327b --- /dev/null +++ b/packages/voxel-renderer/src/network/VoxelSyncServer.ts @@ -0,0 +1,186 @@ +// Import Internal Dependencies +import { VoxelWorld } from "../world/VoxelWorld.ts"; +import type { VoxelWorldJSON } from "../serialization/VoxelSerializer.ts"; +import type { VoxelLayerHookEvent } from "../hooks.ts"; +import { applyCommandToWorld } from "./VoxelCommandApplier.ts"; +import { + LastWriteWinsResolver, + type ConflictResolver +} from "./ConflictResolver.ts"; +import type { VoxelNetworkCommand } from "./types.ts"; + +/** + * A connected client handle. The consumer creates these objects and passes them + * to `VoxelSyncServer.connect()`. The server calls `send()` to transmit data + * back to the real network peer. + */ +export interface ClientHandle { + /** Unique client identifier. */ + readonly id: string; + /** + * Transmit data to this client over the underlying transport. + * The consumer is responsible for framing (JSON-stringify, etc.). + */ + send(data: unknown): void; +} + +export interface VoxelSyncServerOptions { + /** + * Existing `VoxelWorld` to use as the authoritative state. + * A new world is created when omitted. + */ + world?: VoxelWorld; + /** + * Chunk size for the new world (ignored when `world` is provided). + * @default 16 + */ + chunkSize?: number; + /** + * Custom conflict resolver. + * Defaults to `LastWriteWinsResolver`. + */ + conflictResolver?: ConflictResolver; +} + +/** + * Headless, server-authoritative voxel world manager. + * + * Has no Three.js dependency and runs in Node.js / Deno / Bun. + * + * Workflow: + * 1. `connect(client)` — send current snapshot to the joining client. + * 2. `receive(cmd)` — validate, apply to the world, and broadcast to all clients. + * 3. `disconnect(clientId)` — remove the client and notify peers. + * + * @example + * ```ts + * const server = new VoxelSyncServer(); + * + * wss.on("connection", (ws) => { + * const client: ClientHandle = { + * id: crypto.randomUUID(), + * send: (data) => ws.send(JSON.stringify(data)) + * }; + * server.connect(client); + * + * ws.on("message", (raw) => { + * const cmd = JSON.parse(raw.toString()) as VoxelNetworkCommand; + * server.receive(cmd); + * }); + * + * ws.on("close", () => server.disconnect(client.id)); + * }); + * ``` + */ +export class VoxelSyncServer { + readonly world: VoxelWorld; + + #clients = new Map(); + #resolver: ConflictResolver; + #lastCmdByKey = new Map(); + + constructor( + options: VoxelSyncServerOptions = {} + ) { + const { + world, + chunkSize = 16, + conflictResolver + } = options; + + this.world = world ?? new VoxelWorld(chunkSize); + this.#resolver = conflictResolver ?? new LastWriteWinsResolver(); + } + + /** + * Registers a new client and sends them the current world snapshot. + * Notifies all existing peers that a new client has joined. + */ + connect(client: ClientHandle): void { + this.#clients.set(client.id, client); + + // Send the full snapshot to the joining client. + client.send(this.snapshot()); + + // Notify existing peers. + for (const [id, peer] of this.#clients) { + if (id !== client.id) { + peer.send({ type: "peer-joined", peerId: client.id }); + } + } + } + + /** + * Removes a client from the session and notifies remaining peers. + */ + disconnect( + clientId: string + ): void { + this.#clients.delete(clientId); + + for (const peer of this.#clients.values()) { + peer.send({ type: "peer-left", peerId: clientId }); + } + } + + /** + * Processes an incoming command: + * 1. Resolves any conflict with the last accepted command at the same position. + * 2. If accepted: applies the command to the world and broadcasts it. + * 3. If rejected: silently drops it (rollback signals are left to consumers). + */ + receive( + cmd: VoxelNetworkCommand + ): void { + const key = this.#cmdKey(cmd); + const existing = key === null ? + undefined : + this.#lastCmdByKey.get(key); + + const decision = this.#resolver.resolve({ incoming: cmd, existing }); + if (decision === "reject") { + return; + } + + applyCommandToWorld(this.world, cmd); + + if (key !== null) { + this.#lastCmdByKey.set(key, cmd); + } + + // Broadcast the accepted command to all connected clients. + for (const client of this.#clients.values()) { + client.send(cmd); + } + } + + /** + * Produces a full JSON snapshot of the current world state. + * Tilesets are omitted (server is headless; no texture data). + */ + snapshot(): VoxelWorldJSON { + return { + version: 1, + chunkSize: this.world.chunkSize, + tilesets: [], + layers: this.world.getLayers().map((layer) => layer.toJSON()), + objectLayers: [...this.world.getObjectLayers()] + }; + } + + /** + * Returns a stable key for per-position conflict tracking. + * Returns `null` for structural operations that are always accepted. + */ + #cmdKey( + cmd: VoxelLayerHookEvent + ): string | null { + if (cmd.action === "voxel-set" || cmd.action === "voxel-removed") { + const { x, y, z } = cmd.metadata.position as { x: number; y: number; z: number; }; + + return `${cmd.layerName}:${x},${y},${z}`; + } + + return null; + } +} diff --git a/packages/voxel-renderer/src/network/VoxelTransport.ts b/packages/voxel-renderer/src/network/VoxelTransport.ts new file mode 100644 index 0000000..794234b --- /dev/null +++ b/packages/voxel-renderer/src/network/VoxelTransport.ts @@ -0,0 +1,74 @@ +// Import Internal Dependencies +import type { VoxelNetworkCommand } from "./types.ts"; +import type { VoxelWorldJSON } from "../serialization/VoxelSerializer.ts"; + +/** + * Transport-agnostic interface for sending and receiving voxel network commands. + * + * Consumers implement this interface with a concrete transport layer + * (WebSocket, WebRTC, Partykit, BroadcastChannel, etc.) and pass an instance + * to `VoxelSyncClient`. + * + * @example + * ```ts + * class MyWebSocketTransport implements VoxelTransport { + * readonly localClientId = crypto.randomUUID(); + * onCommand: ((cmd: VoxelNetworkCommand) => void) | null = null; + * onSnapshot: ((snapshot: VoxelWorldJSON) => void) | null = null; + * onPeerJoined: ((peerId: string) => void) | null = null; + * onPeerLeft: ((peerId: string) => void) | null = null; + * + * constructor(private ws: WebSocket) { + * ws.addEventListener("message", (ev) => { + * const msg = JSON.parse(ev.data); + * if (msg.type === "snapshot") this.onSnapshot?.(msg.data); + * else if (msg.type === "command") this.onCommand?.(msg.data); + * else if (msg.type === "peer-joined") this.onPeerJoined?.(msg.peerId); + * else if (msg.type === "peer-left") this.onPeerLeft?.(msg.peerId); + * }); + * } + * + * sendCommand(cmd: VoxelNetworkCommand): void { + * this.ws.send(JSON.stringify({ type: "command", data: cmd })); + * } + * + * requestSnapshot(): void { + * this.ws.send(JSON.stringify({ type: "snapshot-request" })); + * } + * } + * ``` + */ +export interface VoxelTransport { + /** The client ID assigned to the local peer by the transport layer. */ + readonly localClientId: string; + + /** Sends a local mutation command to the server / peers. */ + sendCommand(cmd: VoxelNetworkCommand): void; + + /** Requests the current world snapshot from the server. */ + requestSnapshot(): void; + + /** + * Called by the transport when a command arrives from a remote peer. + * Set this before connecting. + */ + onCommand: ((cmd: VoxelNetworkCommand) => void) | null; + + /** + * Called by the transport when the server sends a full world snapshot. + * Set this before connecting. + */ + onSnapshot: ((snapshot: VoxelWorldJSON) => void) | null; + + /** + * Called when a new peer joins the session. + * Set this to react to presence changes. + */ + onPeerJoined: ((peerId: string) => void) | null; + + /** + * Called when a peer leaves the session. + * Set this to react to presence changes. + */ + onPeerLeft: ((peerId: string) => void) | null; +} diff --git a/packages/voxel-renderer/src/network/index.ts b/packages/voxel-renderer/src/network/index.ts new file mode 100644 index 0000000..b7edac8 --- /dev/null +++ b/packages/voxel-renderer/src/network/index.ts @@ -0,0 +1,17 @@ +export type { VoxelNetworkCommand, VoxelNetworkCommandHeader, VoxelSnapshotRequest } from "./types.ts"; +export type { VoxelTransport } from "./VoxelTransport.ts"; +export type { + ConflictContext, + ConflictResolver +} from "./ConflictResolver.ts"; +export { LastWriteWinsResolver } from "./ConflictResolver.ts"; +export { applyCommandToWorld } from "./VoxelCommandApplier.ts"; +export type { + VoxelSyncClientOptions +} from "./VoxelSyncClient.ts"; +export { VoxelSyncClient } from "./VoxelSyncClient.ts"; +export type { + ClientHandle, + VoxelSyncServerOptions +} from "./VoxelSyncServer.ts"; +export { VoxelSyncServer } from "./VoxelSyncServer.ts"; diff --git a/packages/voxel-renderer/src/network/types.ts b/packages/voxel-renderer/src/network/types.ts new file mode 100644 index 0000000..808a70e --- /dev/null +++ b/packages/voxel-renderer/src/network/types.ts @@ -0,0 +1,22 @@ +// Import Internal Dependencies +import type { VoxelLayerHookEvent } from "../hooks.ts"; + +export interface VoxelNetworkCommandHeader { + /** Unique identifier for the originating client. */ + clientId: string; + /** Monotonically increasing sequence number per client. */ + seq: number; + /** Unix timestamp in milliseconds when the command was created. */ + timestamp: number; +} + +/** + * A network command is a hook event enriched with routing metadata. + * It can be sent over any transport (WebSocket, WebRTC, Partykit, etc.). + */ +export type VoxelNetworkCommand = VoxelNetworkCommandHeader & VoxelLayerHookEvent; + +/** Sent by a client that has just joined and needs the current world state. */ +export interface VoxelSnapshotRequest { + clientId: string; +} diff --git a/packages/voxel-renderer/src/types.ts b/packages/voxel-renderer/src/types.ts new file mode 100644 index 0000000..72dfae8 --- /dev/null +++ b/packages/voxel-renderer/src/types.ts @@ -0,0 +1,19 @@ +// Import Third-party Dependencies +import type { Vector3Like } from "three"; + +export interface VoxelSetOptions { + position: Vector3Like; + blockId: number; + /** Y-axis rotation. Use the `VoxelRotation` constants. Default: `VoxelRotation.None` */ + rotation?: 0 | 1 | 2 | 3; + /** Mirror the block around x = 0.5. Default: false */ + flipX?: boolean; + /** Mirror the block around z = 0.5. Default: false */ + flipZ?: boolean; + /** Mirror the block around y = 0.5. Default: false */ + flipY?: boolean; +} + +export interface VoxelRemoveOptions { + position: Vector3Like; +} diff --git a/packages/voxel-renderer/test/network/VoxelCommandApplier.spec.ts b/packages/voxel-renderer/test/network/VoxelCommandApplier.spec.ts new file mode 100644 index 0000000..c7214cb --- /dev/null +++ b/packages/voxel-renderer/test/network/VoxelCommandApplier.spec.ts @@ -0,0 +1,382 @@ +// Import Node.js Dependencies +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; + +// Import Internal Dependencies +import { VoxelWorld } from "../../src/world/VoxelWorld.ts"; +import { applyCommandToWorld } from "../../src/network/VoxelCommandApplier.ts"; +import { packTransform } from "../../src/utils/math.ts"; +import type { VoxelLayerHookEvent } from "../../src/hooks.ts"; + +function makeWorld() { + return new VoxelWorld(4); +} + +// --------------------------------------------------------------------------- +// Layer structural operations +// --------------------------------------------------------------------------- + +describe("applyCommandToWorld — added", () => { + it("creates a new layer in the world", () => { + const world = makeWorld(); + applyCommandToWorld(world, { + action: "added", + layerName: "Ground", + metadata: { options: {} } + }); + assert.ok(world.getLayer("Ground")); + }); + + it("passes options through to the layer", () => { + const world = makeWorld(); + applyCommandToWorld(world, { + action: "added", + layerName: "Deco", + metadata: { options: { visible: false } } + }); + assert.equal(world.getLayer("Deco")?.visible, false); + }); +}); + +describe("applyCommandToWorld — removed", () => { + it("removes an existing layer", () => { + const world = makeWorld(); + world.addLayer("Ground"); + applyCommandToWorld(world, { + action: "removed", + layerName: "Ground", + metadata: {} + }); + assert.equal(world.getLayer("Ground"), undefined); + }); + + it("is a no-op for an unknown layer", () => { + const world = makeWorld(); + assert.doesNotThrow(() => { + applyCommandToWorld(world, { + action: "removed", + layerName: "NoSuch", + metadata: {} + }); + }); + }); +}); + +describe("applyCommandToWorld — updated", () => { + it("updates layer visibility", () => { + const world = makeWorld(); + world.addLayer("Ground"); + applyCommandToWorld(world, { + action: "updated", + layerName: "Ground", + metadata: { options: { visible: false } } + }); + assert.equal(world.getLayer("Ground")?.visible, false); + }); +}); + +describe("applyCommandToWorld — offset-updated (absolute)", () => { + it("sets the layer offset", () => { + const world = makeWorld(); + world.addLayer("Ground"); + applyCommandToWorld(world, { + action: "offset-updated", + layerName: "Ground", + metadata: { offset: { x: 5, y: 0, z: 3 } } + }); + const layer = world.getLayer("Ground")!; + assert.deepEqual(layer.offset, { x: 5, y: 0, z: 3 }); + }); +}); + +describe("applyCommandToWorld — offset-updated (delta)", () => { + it("translates the layer offset", () => { + const world = makeWorld(); + const layer = world.addLayer("Ground"); + layer.offset = { x: 2, y: 0, z: 0 }; + applyCommandToWorld(world, { + action: "offset-updated", + layerName: "Ground", + metadata: { delta: { x: 3, y: 1, z: 0 } } + }); + assert.deepEqual(world.getLayer("Ground")!.offset, { x: 5, y: 1, z: 0 }); + }); +}); + +describe("applyCommandToWorld — reordered", () => { + it("moves a layer to higher priority", () => { + const world = makeWorld(); + const l0 = world.addLayer("Base"); + const l1 = world.addLayer("Top"); + // After sort (descending): [Top(order=1), Base(order=0)] + // Move Base "down" in array index = higher priority (swaps with Top) + applyCommandToWorld(world, { + action: "reordered", + layerName: "Base", + metadata: { direction: "down" } + }); + // Base has now overtaken Top in priority + const layers = world.getLayers(); + assert.equal(layers[0].name, "Base"); + assert.equal(layers[1].name, "Top"); + // reference check to avoid unused-var lint + assert.ok(l0 && l1); + }); +}); + +// --------------------------------------------------------------------------- +// Voxel operations +// --------------------------------------------------------------------------- + +describe("applyCommandToWorld — voxel-set", () => { + it("places a voxel at the given position", () => { + const world = makeWorld(); + world.addLayer("Ground"); + applyCommandToWorld(world, { + action: "voxel-set", + layerName: "Ground", + metadata: { + position: { x: 0, y: 0, z: 0 }, + blockId: 1, + rotation: 0, + flipX: false, + flipZ: false, + flipY: false + } + }); + const entry = world.getLayer("Ground")!.getVoxelAt({ x: 0, y: 0, z: 0 }); + assert.ok(entry); + assert.equal(entry.blockId, 1); + }); + + it("packs rotation and flip flags into the transform", () => { + const world = makeWorld(); + world.addLayer("Ground"); + applyCommandToWorld(world, { + action: "voxel-set", + layerName: "Ground", + metadata: { + position: { x: 1, y: 0, z: 0 }, + blockId: 2, + rotation: 1, + flipX: true, + flipZ: false, + flipY: false + } + }); + const entry = world.getLayer("Ground")!.getVoxelAt({ x: 1, y: 0, z: 0 }); + assert.ok(entry); + assert.equal(entry.transform, packTransform(1, true, false, false)); + }); +}); + +describe("applyCommandToWorld — voxel-removed", () => { + it("removes the voxel at the given position", () => { + const world = makeWorld(); + world.addLayer("Ground"); + world.setVoxelAt("Ground", { x: 0, y: 0, z: 0 }, { blockId: 1, transform: 0 }); + applyCommandToWorld(world, { + action: "voxel-removed", + layerName: "Ground", + metadata: { position: { x: 0, y: 0, z: 0 } } + }); + assert.equal( + world.getLayer("Ground")!.getVoxelAt({ x: 0, y: 0, z: 0 }), + undefined + ); + }); +}); + +describe("applyCommandToWorld — voxels-set (bulk)", () => { + it("places all entries in the world", () => { + const world = makeWorld(); + world.addLayer("Ground"); + const entries = [ + { position: { x: 0, y: 0, z: 0 }, blockId: 1 }, + { position: { x: 1, y: 0, z: 0 }, blockId: 2 }, + { position: { x: 2, y: 0, z: 0 }, blockId: 3 } + ]; + applyCommandToWorld(world, { + action: "voxels-set", + layerName: "Ground", + metadata: { entries } + }); + const layer = world.getLayer("Ground")!; + assert.equal(layer.getVoxelAt({ x: 0, y: 0, z: 0 })?.blockId, 1); + assert.equal(layer.getVoxelAt({ x: 1, y: 0, z: 0 })?.blockId, 2); + assert.equal(layer.getVoxelAt({ x: 2, y: 0, z: 0 })?.blockId, 3); + }); + + it("uses default transform when rotation/flip are omitted", () => { + const world = makeWorld(); + world.addLayer("Ground"); + applyCommandToWorld(world, { + action: "voxels-set", + layerName: "Ground", + metadata: { entries: [{ position: { x: 0, y: 0, z: 0 }, blockId: 5 }] } + }); + const entry = world.getLayer("Ground")!.getVoxelAt({ x: 0, y: 0, z: 0 }); + assert.ok(entry); + assert.equal(entry.transform, packTransform(0, false, false, false)); + }); +}); + +describe("applyCommandToWorld — voxels-removed (bulk)", () => { + it("removes all specified positions", () => { + const world = makeWorld(); + world.addLayer("Ground"); + world.setVoxelAt("Ground", { x: 0, y: 0, z: 0 }, { blockId: 1, transform: 0 }); + world.setVoxelAt("Ground", { x: 1, y: 0, z: 0 }, { blockId: 2, transform: 0 }); + applyCommandToWorld(world, { + action: "voxels-removed", + layerName: "Ground", + metadata: { + entries: [ + { position: { x: 0, y: 0, z: 0 } }, + { position: { x: 1, y: 0, z: 0 } } + ] + } + }); + const layer = world.getLayer("Ground")!; + assert.equal(layer.getVoxelAt({ x: 0, y: 0, z: 0 }), undefined); + assert.equal(layer.getVoxelAt({ x: 1, y: 0, z: 0 }), undefined); + }); +}); + +// --------------------------------------------------------------------------- +// Object layer operations +// --------------------------------------------------------------------------- + +describe("applyCommandToWorld — object-layer-added", () => { + it("creates an object layer", () => { + const world = makeWorld(); + applyCommandToWorld(world, { + action: "object-layer-added", + layerName: "Spawns", + metadata: {} + }); + assert.ok(world.getObjectLayer("Spawns")); + }); +}); + +describe("applyCommandToWorld — object-layer-removed", () => { + it("removes an existing object layer", () => { + const world = makeWorld(); + world.addObjectLayer("Spawns"); + applyCommandToWorld(world, { + action: "object-layer-removed", + layerName: "Spawns", + metadata: {} + }); + assert.equal(world.getObjectLayer("Spawns"), undefined); + }); +}); + +describe("applyCommandToWorld — object-layer-updated", () => { + it("updates object layer visibility", () => { + const world = makeWorld(); + world.addObjectLayer("Spawns"); + applyCommandToWorld(world, { + action: "object-layer-updated", + layerName: "Spawns", + metadata: { patch: { visible: false } } + }); + assert.equal(world.getObjectLayer("Spawns")?.visible, false); + }); +}); + +describe("applyCommandToWorld — object-added", () => { + it("adds an object to the layer", () => { + const world = makeWorld(); + world.addObjectLayer("Spawns"); + const obj = { + id: "obj1", + name: "Spawn Point", + x: 5, + y: 0, + z: 3, + visible: true + }; + applyCommandToWorld(world, { + action: "object-added", + layerName: "Spawns", + metadata: { object: obj } + }); + const layer = world.getObjectLayer("Spawns"); + assert.equal(layer?.objects.length, 1); + assert.equal(layer?.objects[0].id, "obj1"); + }); +}); + +describe("applyCommandToWorld — object-removed", () => { + it("removes an object from the layer", () => { + const world = makeWorld(); + world.addObjectLayer("Spawns"); + world.addObjectToLayer("Spawns", { + id: "obj1", + name: "Spawn", + x: 0, + y: 0, + z: 0, + visible: true + }); + applyCommandToWorld(world, { + action: "object-removed", + layerName: "Spawns", + metadata: { objectId: "obj1" } + }); + assert.equal(world.getObjectLayer("Spawns")?.objects.length, 0); + }); +}); + +describe("applyCommandToWorld — object-updated", () => { + it("patches an object in the layer", () => { + const world = makeWorld(); + world.addObjectLayer("Spawns"); + world.addObjectToLayer("Spawns", { + id: "obj1", + name: "Spawn", + x: 0, + y: 0, + z: 0, + visible: true + }); + applyCommandToWorld(world, { + action: "object-updated", + layerName: "Spawns", + metadata: { objectId: "obj1", patch: { x: 10, visible: false } } + }); + const obj = world.getObjectLayer("Spawns")?.objects[0]; + assert.equal(obj?.x, 10); + assert.equal(obj?.visible, false); + }); +}); + +// --------------------------------------------------------------------------- +// Round-trip: all actions covered +// --------------------------------------------------------------------------- + +describe("applyCommandToWorld — all VoxelLayerHookEvent actions compile", () => { + it("exhaustive switch: no TypeScript error for any action", () => { + // This test confirms TypeScript's exhaustive check works at compile time. + // At runtime we just verify the function is callable without errors. + const actions: VoxelLayerHookEvent["action"][] = [ + "added", + "removed", + "updated", + "offset-updated", + "voxel-set", + "voxel-removed", + "voxels-set", + "voxels-removed", + "reordered", + "object-layer-added", + "object-layer-removed", + "object-layer-updated", + "object-added", + "object-removed", + "object-updated" + ]; + assert.equal(actions.length, 15); + }); +}); diff --git a/packages/voxel-renderer/test/network/VoxelSyncClient.spec.ts b/packages/voxel-renderer/test/network/VoxelSyncClient.spec.ts new file mode 100644 index 0000000..643aa40 --- /dev/null +++ b/packages/voxel-renderer/test/network/VoxelSyncClient.spec.ts @@ -0,0 +1,339 @@ +// Import Node.js Dependencies +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; + +// Import Internal Dependencies +import { VoxelSyncClient } from "../../src/network/VoxelSyncClient.ts"; +import type { VoxelTransport } from "../../src/network/VoxelTransport.ts"; +import type { VoxelNetworkCommand } from "../../src/network/types.ts"; +import type { VoxelLayerHookEvent, VoxelLayerHookListener } from "../../src/hooks.ts"; +import type { VoxelWorldJSON } from "../../src/serialization/VoxelSerializer.ts"; +import type { VoxelRenderer } from "../../src/VoxelRenderer.ts"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +interface MockRenderer { + onLayerUpdated: VoxelLayerHookListener | undefined; + applyRemoteCommand(cmd: VoxelLayerHookEvent): void; + load(data: VoxelWorldJSON): Promise; + // Test helper: simulate a local mutation firing the hook + triggerLocal(event: VoxelLayerHookEvent): void; + appliedCommands: VoxelLayerHookEvent[]; + loadedSnapshots: VoxelWorldJSON[]; +} + +function createMockRenderer(): MockRenderer { + const appliedCommands: VoxelLayerHookEvent[] = []; + const loadedSnapshots: VoxelWorldJSON[] = []; + let listener: VoxelLayerHookListener | undefined; + + const renderer: MockRenderer = { + get onLayerUpdated() { + return listener; + }, + set onLayerUpdated(fn: VoxelLayerHookListener | undefined) { + listener = fn; + }, + applyRemoteCommand(cmd) { + appliedCommands.push(cmd); + }, + load(data) { + loadedSnapshots.push(data); + + return Promise.resolve(); + }, + triggerLocal(event) { + listener?.(event); + }, + appliedCommands, + loadedSnapshots + }; + + return renderer; +} + +interface MockTransport extends VoxelTransport { + sentCommands: VoxelNetworkCommand[]; + // Test helper: simulate receiving a command from a remote peer + simulateCommand(cmd: VoxelNetworkCommand): void; + // Test helper: simulate receiving a snapshot from the server + simulateSnapshot(snapshot: VoxelWorldJSON): void; +} + +function createMockTransport(clientId = "client-A"): MockTransport { + const sentCommands: VoxelNetworkCommand[] = []; + + return { + localClientId: clientId, + sentCommands, + onCommand: null, + onSnapshot: null, + onPeerJoined: null, + onPeerLeft: null, + sendCommand(cmd) { + sentCommands.push(cmd); + }, + requestSnapshot() { + /* no-op */ + }, + simulateCommand(cmd) { + this.onCommand?.(cmd); + }, + simulateSnapshot(snapshot) { + this.onSnapshot?.(snapshot); + } + }; +} + +function makeEmptySnapshot(): VoxelWorldJSON { + return { version: 1, chunkSize: 16, tilesets: [], layers: [] }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("VoxelSyncClient — constructor", () => { + it("sets renderer.onLayerUpdated in the constructor", () => { + const renderer = createMockRenderer(); + const transport = createMockTransport(); + assert.equal(renderer.onLayerUpdated, undefined); + + new VoxelSyncClient({ + renderer: renderer as unknown as VoxelRenderer, + transport + }); + + assert.ok(renderer.onLayerUpdated !== undefined); + }); + + it("sets transport.onCommand in the constructor", () => { + const renderer = createMockRenderer(); + const transport = createMockTransport(); + assert.equal(transport.onCommand, null); + + new VoxelSyncClient({ + renderer: renderer as unknown as VoxelRenderer, + transport + }); + + assert.ok(transport.onCommand !== null); + }); +}); + +describe("VoxelSyncClient — local mutations forwarded to transport", () => { + it("sends a command when a local voxel-set fires", () => { + const renderer = createMockRenderer(); + const transport = createMockTransport("client-A"); + new VoxelSyncClient({ + renderer: renderer as unknown as VoxelRenderer, + transport + }); + + renderer.triggerLocal({ + action: "voxel-set", + layerName: "Ground", + metadata: { + position: { x: 0, y: 0, z: 0 }, + blockId: 1, + rotation: 0, + flipX: false, + flipZ: false, + flipY: false + } + }); + + assert.equal(transport.sentCommands.length, 1); + assert.equal(transport.sentCommands[0].action, "voxel-set"); + assert.equal(transport.sentCommands[0].clientId, "client-A"); + }); + + it("stamps each command with clientId and a timestamp", () => { + const renderer = createMockRenderer(); + const transport = createMockTransport("client-B"); + const before = Date.now(); + new VoxelSyncClient({ + renderer: renderer as unknown as VoxelRenderer, + transport + }); + + renderer.triggerLocal({ + action: "added", + layerName: "Layer1", + metadata: { options: {} } + }); + + const cmd = transport.sentCommands[0]; + assert.equal(cmd.clientId, "client-B"); + assert.ok(cmd.timestamp >= before); + assert.ok(cmd.timestamp <= Date.now()); + }); + + it("increments seq per outbound command", () => { + const renderer = createMockRenderer(); + const transport = createMockTransport(); + new VoxelSyncClient({ + renderer: renderer as unknown as VoxelRenderer, + transport + }); + + renderer.triggerLocal({ action: "added", layerName: "L1", metadata: { options: {} } }); + renderer.triggerLocal({ action: "added", layerName: "L2", metadata: { options: {} } }); + renderer.triggerLocal({ action: "added", layerName: "L3", metadata: { options: {} } }); + + assert.equal(transport.sentCommands[0].seq, 1); + assert.equal(transport.sentCommands[1].seq, 2); + assert.equal(transport.sentCommands[2].seq, 3); + }); +}); + +describe("VoxelSyncClient — remote commands applied without re-emitting", () => { + it("applies commands from a different client to the renderer", () => { + const renderer = createMockRenderer(); + const transport = createMockTransport("client-A"); + new VoxelSyncClient({ + renderer: renderer as unknown as VoxelRenderer, + transport + }); + + const remoteCmd: VoxelNetworkCommand = { + action: "voxel-set", + layerName: "Ground", + metadata: { + position: { x: 5, y: 0, z: 5 }, + blockId: 2, + rotation: 0, + flipX: false, + flipZ: false, + flipY: false + }, + clientId: "client-B", + seq: 1, + timestamp: Date.now() + }; + + transport.simulateCommand(remoteCmd); + + assert.equal(renderer.appliedCommands.length, 1); + assert.equal(renderer.appliedCommands[0].action, "voxel-set"); + }); + + it("does NOT apply commands from the local client (echo prevention)", () => { + const renderer = createMockRenderer(); + const transport = createMockTransport("client-A"); + new VoxelSyncClient({ + renderer: renderer as unknown as VoxelRenderer, + transport + }); + + const echoCmd: VoxelNetworkCommand = { + action: "voxel-set", + layerName: "Ground", + metadata: { + position: { x: 0, y: 0, z: 0 }, + blockId: 1, + rotation: 0, + flipX: false, + flipZ: false, + flipY: false + }, + clientId: "client-A", + seq: 1, + timestamp: Date.now() + }; + + transport.simulateCommand(echoCmd); + + assert.equal(renderer.appliedCommands.length, 0); + }); + + it("does not forward remote commands back to the transport (no loop)", () => { + const renderer = createMockRenderer(); + const transport = createMockTransport("client-A"); + new VoxelSyncClient({ + renderer: renderer as unknown as VoxelRenderer, + transport + }); + + const remoteCmd: VoxelNetworkCommand = { + action: "added", + layerName: "Ground", + metadata: { options: {} }, + clientId: "client-B", + seq: 1, + timestamp: Date.now() + }; + + transport.simulateCommand(remoteCmd); + + // applyRemoteCommand was called — but that doesn't trigger onLayerUpdated + // since #isApplyingRemote suppresses hooks. The mock doesn't simulate + // that suppression, but the absence of extra sentCommands proves no loop. + assert.equal(transport.sentCommands.length, 0); + }); +}); + +describe("VoxelSyncClient — snapshot loading", () => { + it("calls renderer.load when a snapshot arrives", () => { + const renderer = createMockRenderer(); + const transport = createMockTransport(); + new VoxelSyncClient({ + renderer: renderer as unknown as VoxelRenderer, + transport + }); + + const snapshot = makeEmptySnapshot(); + transport.simulateSnapshot(snapshot); + + assert.equal(renderer.loadedSnapshots.length, 1); + assert.equal(renderer.loadedSnapshots[0], snapshot); + }); +}); + +describe("VoxelSyncClient — destroy", () => { + it("clears renderer.onLayerUpdated", () => { + const renderer = createMockRenderer(); + const transport = createMockTransport(); + const client = new VoxelSyncClient({ + renderer: renderer as unknown as VoxelRenderer, + transport + }); + + client.destroy(); + + assert.equal(renderer.onLayerUpdated, undefined); + }); + + it("clears transport.onCommand", () => { + const renderer = createMockRenderer(); + const transport = createMockTransport(); + const client = new VoxelSyncClient({ + renderer: renderer as unknown as VoxelRenderer, + transport + }); + + client.destroy(); + + assert.equal(transport.onCommand, null); + }); + + it("stops forwarding local mutations after destroy", () => { + const renderer = createMockRenderer(); + const transport = createMockTransport(); + const client = new VoxelSyncClient({ + renderer: renderer as unknown as VoxelRenderer, + transport + }); + + client.destroy(); + renderer.triggerLocal({ + action: "added", + layerName: "L", + metadata: { options: {} } + }); + + assert.equal(transport.sentCommands.length, 0); + }); +}); diff --git a/packages/voxel-renderer/test/network/VoxelSyncServer.spec.ts b/packages/voxel-renderer/test/network/VoxelSyncServer.spec.ts new file mode 100644 index 0000000..09fa8d6 --- /dev/null +++ b/packages/voxel-renderer/test/network/VoxelSyncServer.spec.ts @@ -0,0 +1,371 @@ +// Import Node.js Dependencies +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; + +// Import Internal Dependencies +import { + VoxelSyncServer, + type ClientHandle +} from "../../src/network/VoxelSyncServer.ts"; +import { VoxelWorld } from "../../src/world/VoxelWorld.ts"; +import type { VoxelNetworkCommand } from "../../src/network/types.ts"; +import type { VoxelWorldJSON } from "../../src/serialization/VoxelSerializer.ts"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +interface MockClient extends ClientHandle { + received: unknown[]; +} + +function createClient(id: string): MockClient { + const received: unknown[] = []; + + return { + id, + received, + send(data) { + received.push(data); + } + }; +} + +function voxelSetCmd( + opts: { + clientId?: string; + seq?: number; + timestamp?: number; + x?: number; + y?: number; + z?: number; + blockId?: number; + layerName?: string; + } = {} +): VoxelNetworkCommand { + return { + action: "voxel-set", + layerName: opts.layerName ?? "Ground", + metadata: { + position: { x: opts.x ?? 0, y: opts.y ?? 0, z: opts.z ?? 0 }, + blockId: opts.blockId ?? 1, + rotation: 0, + flipX: false, + flipZ: false, + flipY: false + }, + clientId: opts.clientId ?? "client-A", + seq: opts.seq ?? 1, + timestamp: opts.timestamp ?? 1000 + }; +} + +// --------------------------------------------------------------------------- +// snapshot() +// --------------------------------------------------------------------------- + +describe("VoxelSyncServer — snapshot", () => { + it("returns a valid VoxelWorldJSON with version 1", () => { + const server = new VoxelSyncServer(); + const snap = server.snapshot(); + assert.equal(snap.version, 1); + }); + + it("reflects layers that were applied", () => { + const server = new VoxelSyncServer(); + server.world.addLayer("Ground"); + const snap = server.snapshot(); + assert.equal(snap.layers.length, 1); + assert.equal(snap.layers[0].name, "Ground"); + }); + + it("tilesets array is empty (headless server)", () => { + const server = new VoxelSyncServer(); + const snap = server.snapshot(); + assert.deepEqual(snap.tilesets, []); + }); +}); + +// --------------------------------------------------------------------------- +// connect() +// --------------------------------------------------------------------------- + +describe("VoxelSyncServer — connect", () => { + it("sends a snapshot to the newly connected client", () => { + const server = new VoxelSyncServer(); + const client = createClient("A"); + server.connect(client); + + assert.equal(client.received.length, 1); + const snap = client.received[0] as VoxelWorldJSON; + assert.equal(snap.version, 1); + }); + + it("notifies existing peers when a new client joins", () => { + const server = new VoxelSyncServer(); + const clientA = createClient("A"); + const clientB = createClient("B"); + + server.connect(clientA); + // A: 1 snapshot + assert.equal(clientA.received.length, 1); + + server.connect(clientB); + // B: 1 snapshot + assert.equal(clientB.received.length, 1); + // A notified about B + assert.equal(clientA.received.length, 2); + const notification = clientA.received[1] as { type: string; peerId: string; }; + assert.equal(notification.type, "peer-joined"); + assert.equal(notification.peerId, "B"); + }); + + it("does NOT notify the joining client about itself", () => { + const server = new VoxelSyncServer(); + const client = createClient("A"); + server.connect(client); + // Only the snapshot, no self-notification + assert.equal(client.received.length, 1); + }); +}); + +// --------------------------------------------------------------------------- +// disconnect() +// --------------------------------------------------------------------------- + +describe("VoxelSyncServer — disconnect", () => { + it("notifies remaining peers when a client leaves", () => { + const server = new VoxelSyncServer(); + const clientA = createClient("A"); + const clientB = createClient("B"); + + server.connect(clientA); + server.connect(clientB); + + // Clear received so far + clientA.received.length = 0; + clientB.received.length = 0; + + server.disconnect("B"); + + // A is notified + assert.equal(clientA.received.length, 1); + const msg = clientA.received[0] as { type: string; peerId: string; }; + assert.equal(msg.type, "peer-left"); + assert.equal(msg.peerId, "B"); + + // B no longer receives anything + assert.equal(clientB.received.length, 0); + }); +}); + +// --------------------------------------------------------------------------- +// receive() +// --------------------------------------------------------------------------- + +describe("VoxelSyncServer — receive: apply + broadcast", () => { + it("applies the command to the world", () => { + const server = new VoxelSyncServer(); + server.world.addLayer("Ground"); + + server.receive(voxelSetCmd({ x: 2, y: 0, z: 3, blockId: 7 })); + + const entry = server.world.getLayer("Ground")!.getVoxelAt({ x: 2, y: 0, z: 3 }); + assert.ok(entry); + assert.equal(entry.blockId, 7); + }); + + it("broadcasts the command to all connected clients", () => { + const server = new VoxelSyncServer(); + server.world.addLayer("Ground"); + + const clientA = createClient("A"); + const clientB = createClient("B"); + server.connect(clientA); + server.connect(clientB); + clientA.received.length = 0; + clientB.received.length = 0; + + server.receive(voxelSetCmd()); + + assert.equal(clientA.received.length, 1); + assert.equal(clientB.received.length, 1); + const msg = clientA.received[0] as VoxelNetworkCommand; + assert.equal(msg.action, "voxel-set"); + }); + + it("broadcasts structural commands (no key) to all clients", () => { + const server = new VoxelSyncServer(); + const clientA = createClient("A"); + server.connect(clientA); + clientA.received.length = 0; + + const cmd: VoxelNetworkCommand = { + action: "added", + layerName: "Deco", + metadata: { options: {} }, + clientId: "client-X", + seq: 1, + timestamp: 1000 + }; + + server.receive(cmd); + + assert.equal(clientA.received.length, 1); + assert.ok(server.world.getLayer("Deco")); + }); +}); + +describe("VoxelSyncServer — receive: LWW conflict resolution", () => { + it("accepts a command when no prior command exists at that position", () => { + const server = new VoxelSyncServer(); + server.world.addLayer("Ground"); + + server.receive(voxelSetCmd({ timestamp: 500, x: 0, y: 0, z: 0, blockId: 1 })); + + const entry = server.world.getLayer("Ground")!.getVoxelAt({ x: 0, y: 0, z: 0 }); + assert.ok(entry); + assert.equal(entry.blockId, 1); + }); + + it("accepts a later timestamp (LWW wins)", () => { + const server = new VoxelSyncServer(); + server.world.addLayer("Ground"); + + server.receive(voxelSetCmd({ timestamp: 500, x: 0, y: 0, z: 0, blockId: 1, clientId: "A" })); + server.receive(voxelSetCmd({ timestamp: 900, x: 0, y: 0, z: 0, blockId: 2, clientId: "B" })); + + const entry = server.world.getLayer("Ground")!.getVoxelAt({ x: 0, y: 0, z: 0 }); + assert.ok(entry); + assert.equal(entry.blockId, 2); + }); + + it("rejects an earlier timestamp (stale command)", () => { + const server = new VoxelSyncServer(); + server.world.addLayer("Ground"); + + server.receive(voxelSetCmd({ timestamp: 900, x: 0, y: 0, z: 0, blockId: 2, clientId: "A" })); + server.receive(voxelSetCmd({ timestamp: 500, x: 0, y: 0, z: 0, blockId: 1, clientId: "B" })); + + const entry = server.world.getLayer("Ground")!.getVoxelAt({ x: 0, y: 0, z: 0 }); + assert.ok(entry); + // stale command with blockId 1 was rejected — blockId 2 should remain + assert.equal(entry.blockId, 2); + }); + + it("rejects a stale command and does NOT broadcast it", () => { + const server = new VoxelSyncServer(); + server.world.addLayer("Ground"); + + const clientA = createClient("A"); + server.connect(clientA); + clientA.received.length = 0; + + server.receive(voxelSetCmd({ timestamp: 900, x: 0, y: 0, z: 0, blockId: 2 })); + clientA.received.length = 0; + + server.receive(voxelSetCmd({ timestamp: 500, x: 0, y: 0, z: 0, blockId: 1 })); + + // Rejected command — no broadcast + assert.equal(clientA.received.length, 0); + }); + + it("resolves tie by lexicographic clientId", () => { + const server = new VoxelSyncServer(); + server.world.addLayer("Ground"); + + const ts = 1000; + // "client-B" > "client-A" lexicographically + server.receive(voxelSetCmd({ timestamp: ts, x: 0, y: 0, z: 0, blockId: 1, clientId: "client-A" })); + server.receive(voxelSetCmd({ timestamp: ts, x: 0, y: 0, z: 0, blockId: 2, clientId: "client-B" })); + + const entry = server.world.getLayer("Ground")!.getVoxelAt({ x: 0, y: 0, z: 0 }); + assert.ok(entry); + assert.equal(entry.blockId, 2); + }); + + it("does not conflict-check non-voxel commands", () => { + const server = new VoxelSyncServer(); + const clientA = createClient("A"); + server.connect(clientA); + clientA.received.length = 0; + + const cmd1: VoxelNetworkCommand = { + action: "added", + layerName: "Layer", + metadata: { options: {} }, + clientId: "X", + seq: 1, + timestamp: 900 + }; + const cmd2: VoxelNetworkCommand = { + action: "added", + layerName: "Layer2", + metadata: { options: {} }, + clientId: "Y", + seq: 1, + timestamp: 100 + }; + + server.receive(cmd1); + server.receive(cmd2); + + // Both accepted, both broadcast + assert.equal(clientA.received.length, 2); + }); +}); + +describe("VoxelSyncServer — multiple clients receive broadcasts", () => { + it("all connected clients receive the command", () => { + const server = new VoxelSyncServer(); + server.world.addLayer("Ground"); + + const clients = ["A", "B", "C"].map(createClient); + for (const c of clients) { + server.connect(c); + } + // Clear all snapshot + peer-joined notifications accumulated during connect + for (const c of clients) { + c.received.length = 0; + } + + server.receive(voxelSetCmd()); + + for (const c of clients) { + assert.equal(c.received.length, 1, `client ${c.id} should receive the broadcast`); + } + }); + + it("disconnected client does not receive subsequent broadcasts", () => { + const server = new VoxelSyncServer(); + server.world.addLayer("Ground"); + + const clientA = createClient("A"); + const clientB = createClient("B"); + server.connect(clientA); + server.connect(clientB); + + server.disconnect("B"); + clientA.received.length = 0; + clientB.received.length = 0; + + server.receive(voxelSetCmd()); + + assert.equal(clientA.received.length, 1); + assert.equal(clientB.received.length, 0); + }); +}); + +describe("VoxelSyncServer — custom world", () => { + it("accepts an existing VoxelWorld in options", () => { + const world = new VoxelWorld(8); + world.addLayer("PreExisting"); + + const server = new VoxelSyncServer({ world }); + assert.equal(server.world, world); + + const snap = server.snapshot(); + assert.equal(snap.layers.length, 1); + assert.equal(snap.layers[0].name, "PreExisting"); + }); +});