/** * Room.ts augmentations * Monkey-patches some Room methods to improve the testing experience. */ import { Deferred, Room, type Client } from "@colyseus/core"; import { Room as ClientRoom } from "@colyseus/sdk"; // import timers from "timers/promises"; // ---------------------------------------------------------------------------------------- // SERVER-SIDE EXTENSIONS // ---------------------------------------------------------------------------------------- declare module "@colyseus/core" { interface Room { waitForMessage(messageType: string): Promise<[Client, any]>; waitForNextMessage(additionalDelay?: number): Promise; waitForNextPatch(additionalDelay?: number): Promise; waitForNextTimestep(): Promise; /** @deprecated Renamed to {@link Room.waitForNextTimestep}. Forwards unchanged. */ waitForNextSimulationTick(): Promise; _waitingForMessage: [number, Deferred]; _waitingForPatch: [number, Deferred]; _waitingForTimestep: Array<() => void>; } } /* * Wait until receive message */ const _originalOnMessage = Room.prototype['_onMessage']; Room.prototype['_onMessage'] = function(this: Room) { _originalOnMessage.apply(this, arguments as any); if (this._waitingForMessage) { setTimeout(() => this._waitingForMessage[1].resolve(), this._waitingForMessage[0]); } }; Room.prototype.waitForNextMessage = async function(this: Room, additionalDelay: number = 0) { this._waitingForMessage = [additionalDelay, new Deferred()]; return this._waitingForMessage[1]; } Room.prototype.waitForMessage = async function(this: Room, type: string, rejectTimeout: number = 3000) { const originalHandlers = this['onMessageEvents'].events[type] || []; const room = this; return new Promise<[Client, any]>((resolve, reject) => { const rejectionTimeout = setTimeout(() => reject(new Error(`message '${type}' was not called. timed out (${rejectTimeout}ms)`)), rejectTimeout); // Replace handlers with our interceptor room['onMessageEvents'].events[type] = [ async function (client: Client, message: any) { // clear rejection timeout clearTimeout(rejectionTimeout); // call original handlers for (const handler of originalHandlers) { await handler.call(room, client, message); } // revert to original handlers room['onMessageEvents'].events[type] = originalHandlers; // resolves waitForMessage promise. resolve([client, message]); } ]; }); } /** * Wait for the next timestep. * * Works with either loop — `setTimestep()` and `setFixedTimestep()` both drive * the same underlying interval. */ Room.prototype.waitForNextTimestep = async function(this: Room) { if (!this['_simulationInterval']) { console.warn("⚠️ waitForNextTimestep() - the room must call .setTimestep() or .setFixedTimestep()."); return; } return new Promise((resolve) => (this._waitingForTimestep ??= []).push(resolve)); } /** * Resolve everyone waiting on a step, once that step has actually run. * * `setFixedTimestep` drives an accumulator, so a single interval can run zero * steps or several - sleeping for one interval says nothing about how many * executed. Hooking the callback is the only signal that means "a step ran". */ function releaseTimestepWaiters(room: Room) { const waiting = room._waitingForTimestep; if (waiting === undefined || waiting.length === 0) { return; } room._waitingForTimestep = []; for (const resolve of waiting) { resolve(); } } const _originalSetTimestep = Room.prototype.setTimestep; Room.prototype.setTimestep = function(this: Room, onTickCallback?: any, delay?: number) { if (onTickCallback === undefined) { return _originalSetTimestep.call(this, onTickCallback, delay); } return _originalSetTimestep.call(this, (deltaTime: number) => { try { return onTickCallback(deltaTime); } finally { releaseTimestepWaiters(this); } // a throwing step must not strand the waiter }, delay); } const _originalSetFixedTimestep = Room.prototype.setFixedTimestep; Room.prototype.setFixedTimestep = function(this: Room, step: any, tickRate?: number, opts?: any) { return _originalSetFixedTimestep.call(this, (ctx: any) => { try { return step(ctx); } finally { releaseTimestepWaiters(this); } }, tickRate, opts); } /** * @deprecated Renamed to `waitForNextTimestep()`, pairing with the * `setSimulationInterval()` → `setTimestep()` rename in 0.18. Forwards * unchanged; will be removed in 0.19. */ Room.prototype.waitForNextSimulationTick = function(this: Room) { return this.waitForNextTimestep(); } /** * Wait for next patch */ const _originalBroadcastPatch = Room.prototype['broadcastPatch']; Room.prototype['broadcastPatch'] = function(this: Room) { const retVal = _originalBroadcastPatch.call(this); if (this._waitingForPatch) { setTimeout(() => this._waitingForPatch[1].resolve(), this._waitingForPatch[0]); } return retVal; }; Room.prototype.waitForNextPatch = async function (this: Room, additionalDelay: number = 0) { this._waitingForPatch = [additionalDelay, new Deferred()]; return this._waitingForPatch[1]; } // ---------------------------------------------------------------------------------------- // CLIENT-SIDE EXTENSIONS // ---------------------------------------------------------------------------------------- declare module "@colyseus/sdk" { interface Room { waitForMessage(messageType: string, rejectTimeout?: number): Promise; waitForNextMessage(additionalDelay?: number): Promise<[string, any]>; waitForNextPatch(additionalDelay?: number): Promise; waitForInitialState(): Promise; _waitingForMessage: [number, Deferred]; } } ClientRoom.prototype.waitForMessage = async function(this: Room, type: string, rejectTimeout: number = 3000) { return new Promise((resolve, reject) => { const received = (message) => { unbind(); resolve(message); clearTimeout(rejectionTimeout); } const unbind = this['onMessageHandlers'].on(type, (message) => received(message)); const rejectionTimeout = setTimeout(() => { unbind(); reject(new Error(`message '${type}' was not called. timed out (${rejectTimeout}ms)`)); }, rejectTimeout); }); } const _originalClientOnMessage = ClientRoom.prototype['dispatchMessage']; ClientRoom.prototype['dispatchMessage'] = function(this: ClientRoom) { _originalClientOnMessage.apply(this, arguments as any); if (this._waitingForMessage) { setTimeout(() => { this._waitingForMessage[1].resolve([arguments[0], arguments[1]]); }, this._waitingForMessage[0]); } }; ClientRoom.prototype.waitForNextMessage = async function(this: Room, additionalDelay: number = 0) { this._waitingForMessage = [additionalDelay, new Deferred()]; return this._waitingForMessage[1]; } /** * Wait for the next state update to be applied on the client. * * @param additionalDelay - milliseconds to wait after the update is applied. */ ClientRoom.prototype.waitForNextPatch = async function(this: ClientRoom, additionalDelay: number = 0) { // no patch() to hook: decoding goes through serializer.patch() return new Promise((resolve) => this.onStateChange.once(() => setTimeout(resolve, additionalDelay))); } /** * Wait for the room's initial state to be applied on the client. * * Joining settles on the JOIN_ROOM handshake, and the server only sends the * full state once it sees the client's ack - so `room.state` is a round-trip * behind when `join()` resolves. Rooms without state resolve right away. */ ClientRoom.prototype.waitForInitialState = async function(this: ClientRoom) { // mirrors the server's own `if (this.state)` gate: no state, no ROOM_STATE if (this.serializerId === "none") { return; } return new Promise((resolve) => { this.onStateChange.once(() => resolve()); this.onLeave.once(() => resolve()); // rejected after the handshake: don't hang }); }