// Run with: node --import tsx/esm src/game/MultiplayerRoom.test.ts // tsx is already a devDependency; Node 22+ required. import assert from "node:assert/strict"; import fs from "node:fs"; import path from "node:path"; import RAPIER from "@dimforge/rapier3d-compat"; import { MultiplayerRoom } from "./MultiplayerRoom.js"; import { AmmoBelt } from "../types.js"; import { DEFAULT_AIRCRAFT } from "./aircraftData.js"; type ClientInputTuple = [ number, // seq number, // pitch number, // roll number, // yaw number, // throttleDelta number, // boost number, // airbrake number, // primaryFire number, // secondaryFire number, // flaps number // gearDeployed ]; async function testMultiplayerRoom() { console.log("[*] Running MultiplayerRoom unit tests..."); // Initialize Rapier WASM before running tests to prevent async race conditions await RAPIER.init(); const room = new MultiplayerRoom(); room.roomId = "test-room-123"; // Mock Colyseus Room environment methods and properties const broadcastMessages: Array<{ type: string; message: any }> = []; room.broadcast = (type: any, message: any) => { broadcastMessages.push({ type: String(type), message }); }; const messageHandlers = new Map void>(); room.onMessage = (type: any, callback: any) => { messageHandlers.set(String(type), callback); return {} as any; }; const clientMessages: Array<{ sessionId: string; type: string; message: any }> = []; const createMockClient = (sessionId: string, nickname = "Maverick") => { return { sessionId, auth: { nickname, selectedPlaneId: "falcon-mk2", customizations: { skin: "default" }, selectedBelt: AmmoBelt.Universal, equippedMods: { "falcon-mk2": [] } }, send: (type: string, message: any) => { clientMessages.push({ sessionId, type, message }); } } as any; }; // Prevent background simulation thread from starting room.setSimulationInterval = (_cb: any, _delay: any) => { return { close: () => {} } as any; }; // 0.5 Test onAuth authentication console.log("[*] Testing Room authentication (onAuth)..."); const saveDir = fs.existsSync("/data") ? "/data/saves" : path.join(process.cwd(), "saves"); if (!fs.existsSync(saveDir)) { fs.mkdirSync(saveDir, { recursive: true }); } const tempToken = "test_token_auth_unit"; const tempSavePath = path.join(saveDir, `${tempToken}.json`); fs.writeFileSync(tempSavePath, JSON.stringify({ nickname: "Iceman", selectedPlaneId: "falcon-mk2", customizations: { skin: "default" }, selectedBelt: AmmoBelt.Universal, equippedMods: { "falcon-mk2": [] } })); try { const mockAuthClient = {} as any; const authResult = await room.onAuth(mockAuthClient, { token: tempToken, nickname: "Iceman" }); assert.strictEqual(authResult.nickname, "Iceman", "onAuth should successfully validate correct credentials"); const authResult2 = await room.onAuth(mockAuthClient, { token: tempToken, nickname: "Maverick" }); assert.strictEqual(authResult2.nickname, "Maverick", "onAuth should accept different nickname in options"); const guestResult = await room.onAuth(mockAuthClient, { token: "nonexistent_token", nickname: "Iceman" }); assert.strictEqual(guestResult.isGuest, true, "onAuth should return guest session for nonexistent token"); } finally { if (fs.existsSync(tempSavePath)) { fs.unlinkSync(tempSavePath); } } // 1. Test onCreate console.log("[*] Testing Room creation..."); await room.onCreate({ mapId: "island-chain", mode: 1 }); assert.ok(room.state, "MatchState should be initialized"); assert.strictEqual(room.state.matchEnded, false, "Match should not be ended"); assert.strictEqual(room.state.team1Score, 0, "Team 1 score should be 0"); assert.strictEqual(room.state.team2Score, 0, "Team 2 score should be 0"); // By default, fillWithBots spawns 8 bots (4 per team) const botCount = Array.from((room as any).pilots.values()).filter((p: any) => p.isBot).length; assert.strictEqual(botCount, 8, "Should initially spawn exactly 8 bots (4 per team)"); // 2. Test team balancing & bot eviction console.log("[*] Testing team balancing & bot eviction..."); (room as any).pilots.clear(); assert.strictEqual((room as any).setup.balanceTeams((room as any).pilots), 1, "balanceTeams should return 1 when empty"); // Add 1 player to team 1 (room as any).pilots.set("p1", { isBot: false, team: 1 }); assert.strictEqual((room as any).setup.balanceTeams((room as any).pilots), 2, "balanceTeams should return 2 when team 1 has 1 player"); // Add 1 player to team 2 (room as any).pilots.set("p2", { isBot: false, team: 2 }); assert.strictEqual((room as any).setup.balanceTeams((room as any).pilots), 1, "balanceTeams should return 1 when teams are equal"); // Spawn a bot to team 1, and evict it (room as any).setup.botSys.spawnBot(1, DEFAULT_AIRCRAFT[0].id, (room as any).pilots, (room as any).state.players, (id: string, p: any) => (room as any).physSys.addPhysicsBody(id, p), (t: number) => (t === 1 ? Math.PI / 2 : -Math.PI / 2)); const bot1 = Array.from((room as any).pilots.values()).find((p: any) => p.isBot && p.team === 1); assert.ok(bot1, "Bot should be spawned on Team 1"); const botId = (bot1 as any).id; assert.ok((room as any).pilots.has(botId), "Bot should be in pilots map"); assert.ok((room as any).physSys.rigidBodies.has(botId), "Bot should have a rigid body"); // Evict the bot (room as any).setup.evictBot(1, (room as any).pilots, (room as any).physSys, (room as any).playerVoxelGrids); assert.ok(!(room as any).pilots.has(botId), "Bot should be evicted from pilots map"); assert.ok(!(room as any).physSys.rigidBodies.has(botId), "Bot's rigid body should be cleaned up"); // Restore bots for next steps (room as any).pilots.clear(); (room as any).setup.botSys.fillWithBots( 8, (room as any).pilots, (room as any).state.players, (id: string, p: any) => (room as any).physSys.addPhysicsBody(id, p), (t: number) => (t === 1 ? Math.PI / 2 : -Math.PI / 2) ); // 3. Test Player joining console.log("[*] Testing Player joining..."); const client1 = createMockClient("player_session_1", "Iceman"); // Mock balanceTeams to assign to Team 1 (room as any).setup.balanceTeams = () => 1; room.onJoin(client1, { aircraftId: "falcon-mk2", skin: "default" }); assert.ok((room as any).pilots.has(client1.sessionId), "Player pilot should be registered"); assert.ok(room.state.players.has(client1.sessionId), "Player should be added to Colyseus state schema"); const playerPilot = (room as any).pilots.get(client1.sessionId); assert.strictEqual(playerPilot.name, "Iceman", "Pilot name should match auth client data"); assert.strictEqual(playerPilot.team, 1, "Player should be assigned to Team 1"); assert.strictEqual(playerPilot.isBot, false, "Player should not be marked as bot"); // Verify welcome message was sent to client const welcomeMessage = clientMessages.find(m => m.sessionId === client1.sessionId && m.type === "welcome"); assert.ok(welcomeMessage, "Welcome packet should be sent to joining client"); assert.strictEqual(welcomeMessage.message.assignedTeam, 1, "Welcome packet team should be 1"); // 4. Test Chat message handler console.log("[*] Testing Chat message handler..."); const chatHandler = messageHandlers.get("chat"); assert.ok(chatHandler, "Chat handler should be registered"); chatHandler(client1, "Fox Two! Fox Two!"); const lastChatBroadcast = broadcastMessages.find(m => m.type === "chat"); assert.ok(lastChatBroadcast, "Chat message should be broadcast"); assert.strictEqual(lastChatBroadcast.message[1], "player_session_1", "Broadcasted sender ID should match"); assert.strictEqual(lastChatBroadcast.message[2], "Iceman", "Broadcasted sender name should match"); assert.strictEqual(lastChatBroadcast.message[3], "Fox Two! Fox Two!", "Broadcasted text should match"); // 5. Test Input Handling & Queuing edge cases console.log("[*] Testing input queue sorting and limits..."); (room as any).inputSys.inputQueues.set(client1.sessionId, []); // Enqueue 65 inputs to test the queue size ceiling of 60 for (let i = 0; i < 65; i++) { const tuple: ClientInputTuple = [i, 0, 0, 0, 0.5, 0, 0, 0, 0, 0, 0]; (room as any).inputSys.enqueueInput(client1.sessionId, tuple); } const q = (room as any).inputSys.inputQueues.get(client1.sessionId); assert.strictEqual(q.length, 60, "Input queue size should be capped at 60"); assert.strictEqual(q[0].seq, 5, "Oldest inputs (0-4) should have been shifted out"); assert.strictEqual(q[59].seq, 64, "Newest input (64) should be at the tail"); // Test out-of-order sorting (room as any).inputSys.inputQueues.set(client1.sessionId, []); const t5: ClientInputTuple = [5, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]; const t8: ClientInputTuple = [8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]; const t10: ClientInputTuple = [10, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]; (room as any).inputSys.enqueueInput(client1.sessionId, t10); (room as any).inputSys.enqueueInput(client1.sessionId, t5); (room as any).inputSys.enqueueInput(client1.sessionId, t8); const sortedQ = (room as any).inputSys.inputQueues.get(client1.sessionId); assert.strictEqual(sortedQ[0].seq, 5); assert.strictEqual(sortedQ[1].seq, 8); assert.strictEqual(sortedQ[2].seq, 10); // Test queue under-run handles and preserves command state but disables fire (room as any).inputSys.inputQueues.set(client1.sessionId, []); (room as any).projectiles = []; (room as any).inputSys.lastInputs.set(client1.sessionId, { pitch: 0.5, roll: 0.2, yaw: 0.1, throttleDelta: 0, boost: true, airbrake: false, primaryFire: true, secondaryFire: true, flaps: "combat", gearDeployed: false }); const originalRandUnder = Math.random; Math.random = () => 0; // force fire if triggered try { (room as any).tick(); // runs simulation tick, empty queue triggers under-run } finally { Math.random = originalRandUnder; } assert.strictEqual((room as any).projectiles.length, 0, "Queue under-run should disable weapon firing and spawn 0 projectiles"); console.log("[*] All tests passed successfully!"); } testMultiplayerRoom().catch(e => { console.error("FAIL MultiplayerRoom test:", e); process.exit(1); });