From fb2d2c7aa0780f443d46222f2f7df8a182ffe71a Mon Sep 17 00:00:00 2001 From: Franklin Jetty Johnson Date: Wed, 12 Aug 2026 16:04:22 +0530 Subject: [PATCH 1/2] feat: add multiplayer typing races (@jhonsfranky17) Adds a real-time multiplayer mode: create a private room, share the room code or link, race the same word list live against friends, and see ranked results at the end. - Socket.IO layer attached to the existing Express server, authenticated via the existing Firebase token verification - Room state lives in Redis, isolated from the results/PB/XP pipeline so races never affect real stats or leaderboards - Race results are ephemeral and shown once per race, with a rematch option that resets the room back to the lobby - Countdown and word list are synchronized: every player sees the identical sequence, sees a blurred countdown overlay in-game before the race starts, and has their WPM timer force-started at the same instant instead of on their own first keystroke - New /multiplayer and /multiplayer/:roomCode pages, socket client, and Solid state module on the frontend --- backend/package.json | 1 + backend/src/api/controllers/multiplayer.ts | 41 +++ backend/src/api/routes/index.ts | 2 + backend/src/api/routes/multiplayer.ts | 14 + backend/src/init/socket-auth.ts | 34 +++ backend/src/init/socket.ts | 69 +++++ backend/src/server.ts | 8 +- backend/src/sockets/room-handlers.ts | 289 ++++++++++++++++++ backend/src/utils/multiplayer.ts | 178 +++++++++++ frontend/firebase.json | 2 +- frontend/package.json | 1 + frontend/src/html/pages/test.html | 1 + frontend/src/index.html | 4 + .../src/ts/components/layout/header/Nav.tsx | 17 +- frontend/src/ts/components/mount.tsx | 4 + .../pages/multiplayer/CreateOrJoinRoom.tsx | 92 ++++++ .../pages/multiplayer/MultiplayerPage.tsx | 24 ++ .../multiplayer/RaceCountdownOverlay.tsx | 34 +++ .../pages/multiplayer/RaceResultsPage.tsx | 68 +++++ .../pages/multiplayer/RoomLobby.tsx | 210 +++++++++++++ .../src/ts/controllers/page-controller.ts | 13 + .../src/ts/controllers/route-controller.ts | 37 +++ .../ts/input/handlers/before-insert-text.ts | 7 + .../src/ts/input/listeners/composition.ts | 25 +- frontend/src/ts/multiplayer/actions.ts | 164 ++++++++++ frontend/src/ts/multiplayer/socket-client.ts | 41 +++ frontend/src/ts/pages/page.ts | 3 +- frontend/src/ts/states/multiplayer.ts | 119 ++++++++ frontend/src/ts/test/test-logic.ts | 38 ++- frontend/src/ts/test/test-ui.ts | 5 +- frontend/src/ts/test/words-generator.ts | 9 +- packages/contracts/src/index.ts | 2 + packages/contracts/src/multiplayer.ts | 70 +++++ packages/contracts/src/rate-limit/index.ts | 11 + packages/contracts/src/util/api.ts | 3 +- packages/schemas/src/multiplayer-events.ts | 53 ++++ packages/schemas/src/multiplayer.ts | 62 ++++ pnpm-lock.yaml | 217 +++++++------ 38 files changed, 1866 insertions(+), 106 deletions(-) create mode 100644 backend/src/api/controllers/multiplayer.ts create mode 100644 backend/src/api/routes/multiplayer.ts create mode 100644 backend/src/init/socket-auth.ts create mode 100644 backend/src/init/socket.ts create mode 100644 backend/src/sockets/room-handlers.ts create mode 100644 backend/src/utils/multiplayer.ts create mode 100644 frontend/src/ts/components/pages/multiplayer/CreateOrJoinRoom.tsx create mode 100644 frontend/src/ts/components/pages/multiplayer/MultiplayerPage.tsx create mode 100644 frontend/src/ts/components/pages/multiplayer/RaceCountdownOverlay.tsx create mode 100644 frontend/src/ts/components/pages/multiplayer/RaceResultsPage.tsx create mode 100644 frontend/src/ts/components/pages/multiplayer/RoomLobby.tsx create mode 100644 frontend/src/ts/multiplayer/actions.ts create mode 100644 frontend/src/ts/multiplayer/socket-client.ts create mode 100644 frontend/src/ts/states/multiplayer.ts create mode 100644 packages/contracts/src/multiplayer.ts create mode 100644 packages/schemas/src/multiplayer-events.ts create mode 100644 packages/schemas/src/multiplayer.ts diff --git a/backend/package.json b/backend/package.json index c28bee3219fe..d220e9c889c2 100644 --- a/backend/package.json +++ b/backend/package.json @@ -51,6 +51,7 @@ "prom-client": "15.1.3", "rate-limiter-flexible": "5.0.3", "simple-git": "3.36.0", + "socket.io": "4.8.3", "string-similarity": "4.0.4", "swagger-stats": "0.99.7", "ua-parser-js": "0.7.33", diff --git a/backend/src/api/controllers/multiplayer.ts b/backend/src/api/controllers/multiplayer.ts new file mode 100644 index 000000000000..a669170695bb --- /dev/null +++ b/backend/src/api/controllers/multiplayer.ts @@ -0,0 +1,41 @@ +import { + CreateRoomRequest, + CreateRoomResponse, + GetRoomPath, + GetRoomResponse, +} from "@monkeytype/contracts/multiplayer"; +import { MonkeyRequest } from "../types"; +import { MonkeyResponse } from "../../utils/monkey-response"; +import MonkeyError from "../../utils/error"; +import * as UserDAL from "../../dal/user"; +import * as Multiplayer from "../../utils/multiplayer"; + +export async function createRoom( + req: MonkeyRequest, +): Promise { + const { uid } = req.ctx.decodedToken; + const { config } = req.body; + + const { name } = await UserDAL.getPartialUser( + uid, + "create multiplayer room", + ["name"], + ); + + const room = await Multiplayer.createRoom(uid, name, config); + + return new MonkeyResponse("Room created", room); +} + +export async function getRoom( + req: MonkeyRequest, +): Promise { + const { roomCode } = req.params; + + const room = await Multiplayer.getRoom(roomCode); + if (room === null) { + throw new MonkeyError(404, "Room not found"); + } + + return new MonkeyResponse("Room found", room); +} diff --git a/backend/src/api/routes/index.ts b/backend/src/api/routes/index.ts index 911524010796..ddec3a8999c1 100644 --- a/backend/src/api/routes/index.ts +++ b/backend/src/api/routes/index.ts @@ -16,6 +16,7 @@ import configuration from "./configuration"; import { version } from "../../version"; import leaderboards from "./leaderboards"; import connections from "./connections"; +import multiplayer from "./multiplayer"; import addSwaggerMiddlewares from "./swagger"; import { MonkeyResponse } from "../../utils/monkey-response"; import { @@ -60,6 +61,7 @@ const router = s.router(contract, { quotes, webhooks, connections, + multiplayer, }); export function addApiRoutes(app: Application): void { diff --git a/backend/src/api/routes/multiplayer.ts b/backend/src/api/routes/multiplayer.ts new file mode 100644 index 000000000000..3dac12e820cd --- /dev/null +++ b/backend/src/api/routes/multiplayer.ts @@ -0,0 +1,14 @@ +import { multiplayerContract } from "@monkeytype/contracts/multiplayer"; +import { initServer } from "@ts-rest/express"; +import * as MultiplayerController from "../controllers/multiplayer"; +import { callController } from "../ts-rest-adapter"; + +const s = initServer(); +export default s.router(multiplayerContract, { + createRoom: { + handler: async (r) => callController(MultiplayerController.createRoom)(r), + }, + getRoom: { + handler: async (r) => callController(MultiplayerController.getRoom)(r), + }, +}); diff --git a/backend/src/init/socket-auth.ts b/backend/src/init/socket-auth.ts new file mode 100644 index 000000000000..a50a7236f955 --- /dev/null +++ b/backend/src/init/socket-auth.ts @@ -0,0 +1,34 @@ +import { Socket } from "socket.io"; +import { verifyIdToken } from "../utils/auth"; +import Logger from "../utils/logger"; +import { getErrorMessage } from "../utils/error"; + +export type SocketData = { + uid: string; + email: string; + roomCode?: string; +}; + +export async function socketAuthMiddleware( + socket: Socket, + next: (err?: Error) => void, +): Promise { + const token = socket.handshake.auth["token"] as string | undefined; + + if (token === undefined || token === "") { + next(new Error("unauthorized")); + return; + } + + try { + const decoded = await verifyIdToken(token); + (socket.data as SocketData).uid = decoded.uid; + (socket.data as SocketData).email = decoded.email ?? ""; + next(); + } catch (error) { + Logger.warning( + `Rejected socket auth: ${getErrorMessage(error) ?? "unknown error"}`, + ); + next(new Error("unauthorized")); + } +} diff --git a/backend/src/init/socket.ts b/backend/src/init/socket.ts new file mode 100644 index 000000000000..7549028332c0 --- /dev/null +++ b/backend/src/init/socket.ts @@ -0,0 +1,69 @@ +import { Server as HttpServer } from "http"; +import { Server as SocketIOServer } from "socket.io"; +import { RateLimiterMemory, RateLimiterRedis } from "rate-limiter-flexible"; +import type { + ClientToServerEvents, + ServerToClientEvents, +} from "@monkeytype/schemas/multiplayer-events"; +import * as RedisClient from "./redis"; +import { socketAuthMiddleware, SocketData } from "./socket-auth"; +import { registerRoomHandlers } from "../sockets/room-handlers"; +import Logger from "../utils/logger"; + +export type MultiplayerIO = SocketIOServer< + ClientToServerEvents, + ServerToClientEvents, + Record, + SocketData +>; + +let io: MultiplayerIO | undefined; + +const GENERAL_EVENT_LIMIT_PER_SECOND = 20; + +export function attachSocketServer(httpServer: HttpServer): MultiplayerIO { + io = new SocketIOServer(httpServer, { + cors: { origin: true, credentials: true }, + }); + + const connection = RedisClient.getConnection(); + const generalLimiter = connection + ? new RateLimiterRedis({ + storeClient: connection, + keyPrefix: "multiplayer-rl-general", + points: GENERAL_EVENT_LIMIT_PER_SECOND, + duration: 1, + }) + : new RateLimiterMemory({ + points: GENERAL_EVENT_LIMIT_PER_SECOND, + duration: 1, + }); + + io.use(socketAuthMiddleware); + + io.on("connection", (socket) => { + Logger.info(`Multiplayer socket connected: ${socket.data.uid}`); + + socket.use((event, next) => { + generalLimiter + .consume(socket.data.uid) + .then(() => next()) + .catch(() => { + // rate limited: silently drop the event rather than disconnecting, + // since a burst is likely a slow client tab, not abuse + next(new Error("rate limited")); + }); + }); + + registerRoomHandlers(io as MultiplayerIO, socket); + }); + + return io; +} + +export function getIO(): MultiplayerIO { + if (!io) { + throw new Error("Socket.IO server has not been attached yet"); + } + return io; +} diff --git a/backend/src/server.ts b/backend/src/server.ts index fc66651b3767..dd2f55c96eda 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -6,7 +6,8 @@ import { updateFromConfigurationFile, } from "./init/configuration"; import app from "./app"; -import { Server } from "http"; +import { createServer, Server } from "http"; +import { attachSocketServer } from "./init/socket"; import { version } from "./version"; import { recordServerVersion } from "./utils/prometheus"; import * as RedisClient from "./init/redis"; @@ -89,7 +90,10 @@ async function bootServer(port: number): Promise { return process.exit(1); } - return app.listen(port, () => { + const httpServer = createServer(app); + attachSocketServer(httpServer); + + return httpServer.listen(port, () => { Logger.success(`API server listening on port ${port}`); }); } diff --git a/backend/src/sockets/room-handlers.ts b/backend/src/sockets/room-handlers.ts new file mode 100644 index 000000000000..70eeedb2de11 --- /dev/null +++ b/backend/src/sockets/room-handlers.ts @@ -0,0 +1,289 @@ +import { Socket } from "socket.io"; +import * as UserDAL from "../dal/user"; +import * as Multiplayer from "../utils/multiplayer"; +import Logger from "../utils/logger"; +import { getErrorMessage } from "../utils/error"; +import { RaceConfigSchema } from "@monkeytype/schemas/multiplayer"; +import type { + ClientToServerEvents, + ServerToClientEvents, +} from "@monkeytype/schemas/multiplayer-events"; +import { MultiplayerIO } from "../init/socket"; +import { SocketData } from "../init/socket-auth"; + +export type RoomSocket = Socket< + ClientToServerEvents, + ServerToClientEvents, + Record, + SocketData +>; + +const COUNTDOWN_SECONDS = 10; +const RACE_FINISH_TIMEOUT_BUFFER_MS = 60_000; +const RACE_FINISH_TIMEOUT_MAX_MS = 5 * 60_000; + +// per-room, in-memory only: the finish-timeout handle for the room. +const finishTimeoutByRoom = new Map(); + +async function getDisplayName(uid: string): Promise { + try { + const user = await UserDAL.getPartialUser(uid, "multiplayer", ["name"]); + return user.name; + } catch { + return "unknown"; + } +} + +function clearRoomTimers(roomCode: string): void { + const timeout = finishTimeoutByRoom.get(roomCode); + if (timeout) { + clearTimeout(timeout); + finishTimeoutByRoom.delete(roomCode); + } +} + +async function finishRaceIfDone( + io: MultiplayerIO, + roomCode: string, +): Promise { + const room = await Multiplayer.getRoom(roomCode); + if (room === null || room.status === "finished") { + return; + } + + if (!Multiplayer.allConnectedPlayersFinished(room)) { + return; + } + + room.status = "finished"; + await Multiplayer.saveRoom(room); + clearRoomTimers(roomCode); + io.to(roomCode).emit("race:results", { players: room.players }); +} + +export function registerRoomHandlers( + io: MultiplayerIO, + socket: RoomSocket, +): void { + const uid = socket.data.uid; + + socket.on("room:join", (payload, ack) => { + void (async () => { + try { + const room = await Multiplayer.getRoom(payload.roomCode); + if (room === null) { + ack({ status: "error", message: "Room not found" }); + return; + } + + const name = await getDisplayName(uid); + const updated = Multiplayer.addPlayer(room, uid, name); + await Multiplayer.saveRoom(updated); + + await socket.join(updated.roomCode); + socket.data.roomCode = updated.roomCode; + + const player = updated.players.find((it) => it.uid === uid); + if (player) { + socket.to(updated.roomCode).emit("room:playerJoined", player); + } + + ack({ status: "ok", data: updated }); + } catch (error) { + Logger.error(`room:join failed: ${getErrorMessage(error)}`); + ack({ status: "error", message: "Failed to join room" }); + } + })(); + }); + + socket.on("room:leave", (payload) => { + void handleLeave(io, socket, payload.roomCode); + }); + + socket.on("disconnect", () => { + const roomCode = socket.data.roomCode; + if (roomCode !== undefined && roomCode !== "") { + void handleLeave(io, socket, roomCode); + } + }); + + socket.on("room:updateConfig", (payload) => { + void (async () => { + const room = await Multiplayer.getRoom(payload.roomCode); + if (room === null || room.hostUid !== uid || room.status !== "lobby") { + return; + } + + const parsed = RaceConfigSchema.safeParse(payload.config); + if (!parsed.success) { + socket.emit("room:error", { + message: "Invalid race config", + code: "invalid_config", + }); + return; + } + + room.config = parsed.data; + await Multiplayer.saveRoom(room); + io.to(room.roomCode).emit("room:state", room); + })(); + }); + + socket.on("room:setReady", (payload) => { + void (async () => { + const room = await Multiplayer.getRoom(payload.roomCode); + if (room === null) return; + + const player = room.players.find((it) => it.uid === uid); + if (!player) return; + + player.isReady = payload.isReady; + await Multiplayer.saveRoom(room); + io.to(room.roomCode).emit("room:state", room); + })(); + }); + + socket.on("room:rematch", (payload) => { + void (async () => { + const room = await Multiplayer.getRoom(payload.roomCode); + if (room === null || room.hostUid !== uid || room.status !== "finished") { + return; + } + + room.status = "lobby"; + room.wordList = null; + room.raceStartedAt = null; + for (const player of room.players) { + player.isReady = false; + player.finalResult = null; + } + + await Multiplayer.saveRoom(room); + io.to(room.roomCode).emit("room:state", room); + })(); + }); + + socket.on("room:start", (payload) => { + void (async () => { + const room = await Multiplayer.getRoom(payload.roomCode); + if (room === null || room.hostUid !== uid || room.status !== "lobby") { + return; + } + + const connectedPlayers = room.players.filter( + (player) => player.status === "connected", + ); + const allReady = connectedPlayers.every((player) => player.isReady); + if (!allReady || payload.wordList.length === 0) { + socket.emit("room:error", { + message: "All players must be ready to start", + code: "not_ready", + }); + return; + } + + room.status = "countdown"; + room.wordList = payload.wordList; + await Multiplayer.saveRoom(room); + + const startsAtServerTime = Date.now() + COUNTDOWN_SECONDS * 1000; + io.to(room.roomCode).emit("room:countdown", { + startsAtServerTime, + wordList: payload.wordList, + seconds: COUNTDOWN_SECONDS, + }); + + setTimeout(() => { + void (async () => { + const startedRoom = await Multiplayer.getRoom(room.roomCode); + if (startedRoom?.status !== "countdown") { + return; + } + startedRoom.status = "racing"; + startedRoom.raceStartedAt = Date.now(); + await Multiplayer.saveRoom(startedRoom); + + io.to(room.roomCode).emit("room:raceStarted", { + wordList: startedRoom.wordList ?? [], + raceStartedAt: startedRoom.raceStartedAt, + }); + + const finishTimeoutMs = Math.min( + startedRoom.config.mode === "time" + ? startedRoom.config.time * 1000 * 2 + : RACE_FINISH_TIMEOUT_BUFFER_MS * 2, + RACE_FINISH_TIMEOUT_MAX_MS, + ); + const timeout = setTimeout(() => { + void finishRaceIfDone(io, room.roomCode); + }, finishTimeoutMs); + finishTimeoutByRoom.set(room.roomCode, timeout); + })(); + }, COUNTDOWN_SECONDS * 1000); + })(); + }); + + socket.on("race:finish", (payload) => { + void (async () => { + const room = await Multiplayer.getRoom(payload.roomCode); + if (room === null) return; + + const finalResult = { + wpm: payload.wpm, + rawWpm: payload.rawWpm, + acc: payload.acc, + consistency: payload.consistency, + charStats: payload.charStats, + finishedAt: Date.now(), + }; + + const updated = Multiplayer.recordFinalResult(room, uid, finalResult); + await Multiplayer.saveRoom(updated); + + io.to(payload.roomCode).emit("race:playerFinished", { + uid, + finalResult, + }); + + await finishRaceIfDone(io, payload.roomCode); + })(); + }); +} + +async function handleLeave( + io: MultiplayerIO, + socket: RoomSocket, + roomCode: string, +): Promise { + const uid = socket.data.uid; + await socket.leave(roomCode); + + const room = await Multiplayer.getRoom(roomCode); + if (room === null) return; + + const updated = Multiplayer.markPlayerStatus(room, uid, "disconnected"); + + // host disconnected pre-race: promote the next connected player + if ( + updated.hostUid === uid && + updated.status === "lobby" && + updated.players.some((p) => p.status === "connected" && p.uid !== uid) + ) { + const newHost = updated.players.find( + (p) => p.status === "connected" && p.uid !== uid, + ); + if (newHost) { + updated.hostUid = newHost.uid; + newHost.isHost = true; + const oldHost = updated.players.find((p) => p.uid === uid); + if (oldHost) oldHost.isHost = false; + } + } + + await Multiplayer.saveRoom(updated); + io.to(roomCode).emit("room:playerLeft", { uid }); + + if (updated.status === "racing") { + await finishRaceIfDone(io, roomCode); + } +} diff --git a/backend/src/utils/multiplayer.ts b/backend/src/utils/multiplayer.ts new file mode 100644 index 000000000000..ec84aab80707 --- /dev/null +++ b/backend/src/utils/multiplayer.ts @@ -0,0 +1,178 @@ +import crypto from "crypto"; +import * as RedisClient from "../init/redis"; +import { + RaceConfig, + RaceFinalResult, + RacePlayer, + RaceRoom, +} from "@monkeytype/schemas/multiplayer"; + +const NAMESPACE = "monkeytype:multiplayer"; +const ROOM_TTL_SECONDS = 2 * 60 * 60; // 2 hours, while a room is active +const FINISHED_ROOM_TTL_SECONDS = 10 * 60; // 10 minutes, once a race is over +const ROOM_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // no 0/O/1/I +const ROOM_CODE_LENGTH = 6; +const ROOM_CODE_MAX_ATTEMPTS = 10; + +function roomKey(roomCode: string): string { + return `${NAMESPACE}:room:${roomCode}`; +} + +function userCodesKey(uid: string): string { + return `${NAMESPACE}:usercodes:${uid}`; +} + +function generateRoomCode(): string { + let code = ""; + for (let i = 0; i < ROOM_CODE_LENGTH; i++) { + code += ROOM_CODE_ALPHABET[crypto.randomInt(ROOM_CODE_ALPHABET.length)]; + } + return code; +} + +async function reserveUniqueRoomCode(): Promise { + const connection = RedisClient.getConnection(); + if (!connection) { + throw new Error("Redis connection not available"); + } + + for (let attempt = 0; attempt < ROOM_CODE_MAX_ATTEMPTS; attempt++) { + const code = generateRoomCode(); + // "reserve" the key with a placeholder value; the caller overwrites it + // with the real room JSON immediately after. NX makes the check + claim atomic. + const reserved = await connection.set( + roomKey(code), + "", + "EX", + ROOM_TTL_SECONDS, + "NX", + ); + if (reserved === "OK") { + return code; + } + } + + throw new Error("Failed to generate a unique room code"); +} + +export async function createRoom( + hostUid: string, + hostName: string, + config: RaceConfig, +): Promise { + const roomCode = await reserveUniqueRoomCode(); + + const room: RaceRoom = { + roomCode, + hostUid, + status: "lobby", + config, + wordList: null, + players: [ + { + uid: hostUid, + name: hostName, + isHost: true, + isReady: false, + status: "connected", + finalResult: null, + }, + ], + createdAt: Date.now(), + raceStartedAt: null, + }; + + await saveRoom(room); + await addUserRoomCode(hostUid, roomCode); + + return room; +} + +export async function getRoom(roomCode: string): Promise { + const connection = RedisClient.getConnection(); + if (!connection) { + return null; + } + + const raw = await connection.get(roomKey(roomCode)); + if (raw === null || raw === "") { + return null; + } + + return JSON.parse(raw) as RaceRoom; +} + +export async function saveRoom(room: RaceRoom): Promise { + const connection = RedisClient.getConnection(); + if (!connection) { + throw new Error("Redis connection not available"); + } + + const ttl = + room.status === "finished" ? FINISHED_ROOM_TTL_SECONDS : ROOM_TTL_SECONDS; + + await connection.set(roomKey(room.roomCode), JSON.stringify(room), "EX", ttl); +} + +async function addUserRoomCode(uid: string, roomCode: string): Promise { + const connection = RedisClient.getConnection(); + if (!connection) { + return; + } + + await connection.sadd(userCodesKey(uid), roomCode); + await connection.expire(userCodesKey(uid), ROOM_TTL_SECONDS); +} + +export function addPlayer(room: RaceRoom, uid: string, name: string): RaceRoom { + const existing = room.players.find((player) => player.uid === uid); + if (existing) { + existing.status = "connected"; + return room; + } + + room.players.push({ + uid, + name, + isHost: false, + isReady: false, + status: "connected", + finalResult: null, + }); + + return room; +} + +export function markPlayerStatus( + room: RaceRoom, + uid: string, + status: RacePlayer["status"], +): RaceRoom { + const player = room.players.find((it) => it.uid === uid); + if (player) { + player.status = status; + } + return room; +} + +export function recordFinalResult( + room: RaceRoom, + uid: string, + result: RaceFinalResult, +): RaceRoom { + const player = room.players.find((it) => it.uid === uid); + if (player) { + player.finalResult = result; + } + return room; +} + +export function allConnectedPlayersFinished(room: RaceRoom): boolean { + const relevantPlayers = room.players.filter( + (player) => player.status === "connected", + ); + if (relevantPlayers.length === 0) { + return true; + } + return relevantPlayers.every((player) => player.finalResult !== null); +} diff --git a/frontend/firebase.json b/frontend/firebase.json index d7603d4c7da8..37120ea85627 100644 --- a/frontend/firebase.json +++ b/frontend/firebase.json @@ -4,7 +4,7 @@ "ignore": ["firebase.json", "**/.*", "**/node_modules/**"], "rewrites": [ { - "source": "/@(test|settings|account|about|login|profile|friends|account-settings|leaderboards){,/**}", + "source": "/@(test|settings|account|about|login|profile|friends|account-settings|leaderboards|multiplayer){,/**}", "destination": "/index.html" }, { diff --git a/frontend/package.json b/frontend/package.json index 9234f3fdf969..6b797dc05dd5 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -65,6 +65,7 @@ "modern-screenshot": "4.6.8", "object-hash": "3.0.0", "slim-select": "2.9.2", + "socket.io-client": "4.8.3", "stemmer": "2.0.1", "tailwind-merge": "3.6.0", "throttle-debounce": "5.0.2", diff --git a/frontend/src/html/pages/test.html b/frontend/src/html/pages/test.html index 419d735b4582..7cb2dea6a203 100644 --- a/frontend/src/html/pages/test.html +++ b/frontend/src/html/pages/test.html @@ -41,6 +41,7 @@ spellcheck="false" > +
diff --git a/frontend/src/index.html b/frontend/src/index.html index 4e2504b4503c..6c5e8f0bce58 100644 --- a/frontend/src/index.html +++ b/frontend/src/index.html @@ -68,6 +68,10 @@ + +
+ +
+ + + + 0}> +
+

+
+ Create a room, then share the room code or link with your friends + from the friends list to invite them to race. +
+

+
+ + ); +} diff --git a/frontend/src/ts/components/pages/multiplayer/MultiplayerPage.tsx b/frontend/src/ts/components/pages/multiplayer/MultiplayerPage.tsx new file mode 100644 index 000000000000..ebdbaafd21dd --- /dev/null +++ b/frontend/src/ts/components/pages/multiplayer/MultiplayerPage.tsx @@ -0,0 +1,24 @@ +import { Show } from "solid-js"; + +import { getRoom, getRaceResults } from "../../../states/multiplayer"; +import { Page } from "../../common/Page"; +import { CreateOrJoinRoom } from "./CreateOrJoinRoom"; +import { RaceResultsPage } from "./RaceResultsPage"; +import { RoomLobby } from "./RoomLobby"; + +export function MultiplayerPage() { + return ( + + }> + + + } + > + + + + ); +} diff --git a/frontend/src/ts/components/pages/multiplayer/RaceCountdownOverlay.tsx b/frontend/src/ts/components/pages/multiplayer/RaceCountdownOverlay.tsx new file mode 100644 index 000000000000..d85834c951e1 --- /dev/null +++ b/frontend/src/ts/components/pages/multiplayer/RaceCountdownOverlay.tsx @@ -0,0 +1,34 @@ +import { createEffect, createSignal, onCleanup, Show } from "solid-js"; + +import { getRaceCountdown } from "../../../states/multiplayer"; + +export function RaceCountdownOverlay() { + const [secondsLeft, setSecondsLeft] = createSignal(null); + + createEffect(() => { + const countdown = getRaceCountdown(); + if (countdown === null) { + setSecondsLeft(null); + return; + } + + const tick = (): void => { + const remainingMs = countdown.startsAtServerTime - Date.now(); + setSecondsLeft(Math.max(0, Math.ceil(remainingMs / 1000))); + }; + tick(); + + const interval = setInterval(tick, 100); + onCleanup(() => clearInterval(interval)); + }); + + return ( + +
+
+ {secondsLeft() === 0 ? "go!" : secondsLeft()} +
+
+
+ ); +} diff --git a/frontend/src/ts/components/pages/multiplayer/RaceResultsPage.tsx b/frontend/src/ts/components/pages/multiplayer/RaceResultsPage.tsx new file mode 100644 index 000000000000..7d595d04507c --- /dev/null +++ b/frontend/src/ts/components/pages/multiplayer/RaceResultsPage.tsx @@ -0,0 +1,68 @@ +import { createMemo, For, Show } from "solid-js"; + +import { getAuthenticatedUser } from "../../../firebase"; +import { rematch, leaveCurrentRoom } from "../../../multiplayer/actions"; +import { getRoom, getRaceResults } from "../../../states/multiplayer"; +import { Button } from "../../common/Button"; +import { H2 } from "../../common/Headers"; + +export function RaceResultsPage() { + const myUid = () => getAuthenticatedUser()?.uid; + const isHost = () => getRoom()?.hostUid === myUid(); + + const ranked = createMemo(() => + [...(getRaceResults() ?? [])].sort((a, b) => { + if (a.finalResult === null) return 1; + if (b.finalResult === null) return -1; + return b.finalResult.wpm - a.finalResult.wpm; + }), + ); + + return ( +
+

+ +
+ + {(player, index) => ( +
+ #{index() + 1} + {player.name} + did not finish} + > + {(finalResult) => ( + <> + {Math.round(finalResult().wpm)} wpm + + {Math.round(finalResult().acc)}% acc + + + )} + +
+ )} +
+
+ +
+ +
+

+ ); +} diff --git a/frontend/src/ts/components/pages/multiplayer/RoomLobby.tsx b/frontend/src/ts/components/pages/multiplayer/RoomLobby.tsx new file mode 100644 index 000000000000..2b9b4e49297e --- /dev/null +++ b/frontend/src/ts/components/pages/multiplayer/RoomLobby.tsx @@ -0,0 +1,210 @@ +import type { RaceConfig } from "@monkeytype/schemas/multiplayer"; + +import { useQuery } from "@tanstack/solid-query"; +import { createMemo, For, Show } from "solid-js"; + +import { getAuthenticatedUser } from "../../../firebase"; +import { + setReady, + updateRaceConfig, + startRace, + leaveCurrentRoom, +} from "../../../multiplayer/actions"; +import { getFriendsListQuery } from "../../../queries/friends"; +import { getRoom } from "../../../states/multiplayer"; +import { + showSuccessNotification, + showErrorNotification, +} from "../../../states/notifications"; +import { Button } from "../../common/Button"; +import { H2, H3 } from "../../common/Headers"; + +export function RoomLobby() { + const room = createMemo(() => getRoom()); + const myUid = () => getAuthenticatedUser()?.uid; + const isHost = () => room()?.hostUid === myUid(); + const me = () => room()?.players.find((player) => player.uid === myUid()); + + const friendsQuery = useQuery(() => getFriendsListQuery()); + + const shareLink = (): string => + `${window.location.origin}/multiplayer/${room()?.roomCode ?? ""}`; + + const copyLink = async (): Promise => { + await navigator.clipboard.writeText(shareLink()); + showSuccessNotification("Room link copied to clipboard", { + durationMs: 2000, + }); + }; + + const connectedPlayers = createMemo( + () => + room()?.players.filter((player) => player.status === "connected") ?? [], + ); + const allReady = createMemo( + () => + connectedPlayers().length > 0 && + connectedPlayers().every((player) => player.isReady), + ); + + const setConfigField = ( + key: K, + value: RaceConfig[K], + ): void => { + const current = room()?.config; + if (current === undefined) return; + updateRaceConfig({ ...current, [key]: value }); + }; + + return ( +
+
+

+
+
+

+ +
+

+
+ + {(player) => ( +
+ + {player.name} + (host) + + {" "} + (disconnected) + + + + {player.isReady ? "ready" : "not ready"} + +
+ )} +
+
+

+ + +
+

+
+
+

+
+ +
+
+ + 0}> +
+

+
+ + {(friend) => ( +
+ {friend.name} +
+ )} +
+
+

+
+
+ ); +} diff --git a/frontend/src/ts/controllers/page-controller.ts b/frontend/src/ts/controllers/page-controller.ts index 5550c12cbe8b..acdc0a44c785 100644 --- a/frontend/src/ts/controllers/page-controller.ts +++ b/frontend/src/ts/controllers/page-controller.ts @@ -37,6 +37,7 @@ import { AccountSettingsUrlParamsSchema, readAccountSettingsGetParameters, } from "../states/account-settings"; +import * as MultiplayerActions from "../multiplayer/actions"; type ChangeOptions = { force?: boolean; @@ -134,6 +135,18 @@ const pages = { ], }, }), + multiplayer: solidPage("multiplayer", { + beforeShow: async (options) => { + const roomCode = options.params?.["roomCode"]; + if (roomCode !== undefined && roomCode !== "") { + try { + await MultiplayerActions.joinRoomByCode(roomCode); + } catch { + // error notification already shown by joinRoomByCode + } + } + }, + }), accountSettings: solidPage("accountSettings", { urlParamsSchema: AccountSettingsUrlParamsSchema, beforeShow: async (options) => { diff --git a/frontend/src/ts/controllers/route-controller.ts b/frontend/src/ts/controllers/route-controller.ts index 595c09900097..951e35e082fd 100644 --- a/frontend/src/ts/controllers/route-controller.ts +++ b/frontend/src/ts/controllers/route-controller.ts @@ -155,6 +155,43 @@ const routes: Route[] = [ await PageController.change("friends", options); }, }, + { + path: "/multiplayer", + load: async (_params, options) => { + if (!isAuthAvailable()) { + await navigate("/", options); + return; + } + if (!isAuthenticated()) { + await navigate("/login", options); + return; + } + + await PageController.change("multiplayer", options); + }, + }, + { + path: "/multiplayer/:roomCode", + load: async (params, options) => { + if (!isAuthAvailable()) { + await navigate("/", options); + return; + } + if (!isAuthenticated()) { + await navigate("/login", options); + return; + } + + await PageController.change("multiplayer", { + ...options, + force: true, + params: { + roomCode: params["roomCode"] as string, + }, + data: options.data, + }); + }, + }, ]; export async function navigate( diff --git a/frontend/src/ts/input/handlers/before-insert-text.ts b/frontend/src/ts/input/handlers/before-insert-text.ts index 2bf264e8c25f..4b3d89165df9 100644 --- a/frontend/src/ts/input/handlers/before-insert-text.ts +++ b/frontend/src/ts/input/handlers/before-insert-text.ts @@ -15,6 +15,7 @@ import { shouldGoToNextWord } from "../helpers/validation"; import { getCommitCharacterType, normalizeData } from "../helpers/util"; import { getCurrentInput } from "../../test/events/data"; import { isSpace } from "../../utils/strings"; +import { getRaceCountdown } from "../../states/multiplayer"; /** * Handles logic before inserting text into the input element. @@ -22,6 +23,12 @@ import { isSpace } from "../../utils/strings"; * @returns Whether to prevent the default insertion behavior. */ export function onBeforeInsertText(data: string): boolean { + // multiplayer race hasn't started yet: words are visible (blurred) but + // typing shouldn't count, or start the timer, until the synchronized "go" + if (getRaceCountdown() !== null) { + return true; + } + if (isTestRestarting()) { return true; } diff --git a/frontend/src/ts/input/listeners/composition.ts b/frontend/src/ts/input/listeners/composition.ts index 49eb62ab3070..4dbd9376edf8 100644 --- a/frontend/src/ts/input/listeners/composition.ts +++ b/frontend/src/ts/input/listeners/composition.ts @@ -11,6 +11,7 @@ import { isTestActive, setCompositionText, } from "../../states/test"; +import { getRaceCountdown } from "../../states/multiplayer"; const inputEl = getInputElement(); @@ -22,7 +23,13 @@ inputEl.addEventListener("compositionstart", (event) => { const now = performance.now(); - if (isTestRestarting() || isResultCalculating()) return; + if ( + isTestRestarting() || + isResultCalculating() || + getRaceCountdown() !== null + ) { + return; + } CompositionState.setComposing(true); CompositionState.setData(""); setLastInsertCompositionTextData(""); @@ -42,7 +49,13 @@ inputEl.addEventListener("compositionupdate", (event) => { data: event.data, }); - if (isTestRestarting() || isResultCalculating()) return; + if ( + isTestRestarting() || + isResultCalculating() || + getRaceCountdown() !== null + ) { + return; + } CompositionState.setData(event.data); setCompositionText(event.data); @@ -58,7 +71,13 @@ inputEl.addEventListener("compositionupdate", (event) => { inputEl.addEventListener("compositionend", async (event) => { console.debug("wordsInput event compositionend", { event, data: event.data }); - if (isTestRestarting() || isResultCalculating()) return; + if ( + isTestRestarting() || + isResultCalculating() || + getRaceCountdown() !== null + ) { + return; + } CompositionState.setComposing(false); CompositionState.setData(""); setCompositionText(""); diff --git a/frontend/src/ts/multiplayer/actions.ts b/frontend/src/ts/multiplayer/actions.ts new file mode 100644 index 000000000000..5b540dfc33e7 --- /dev/null +++ b/frontend/src/ts/multiplayer/actions.ts @@ -0,0 +1,164 @@ +import Ape from "../ape"; +import * as TestLogic from "../test/test-logic"; +import * as WordsGenerator from "../test/words-generator"; +import * as JSONData from "../utils/json-data"; +import { setConfig } from "../config/setters"; +import { navigationEvent } from "../events/navigation"; +import { showErrorNotification } from "../states/notifications"; +import { connect as connectSocket, getSocket } from "./socket-client"; +import { + getRoom, + setRoom, + setRoomError, + initMultiplayerListeners, + leaveRoom, +} from "../states/multiplayer"; +import type { RaceConfig } from "@monkeytype/schemas/multiplayer"; + +let raceListenersRegistered = false; + +export async function ensureConnected(): Promise { + await connectSocket(); + initMultiplayerListeners(); + registerRaceListeners(); +} + +function registerRaceListeners(): void { + if (raceListenersRegistered) return; + raceListenersRegistered = true; + + // As soon as the countdown starts (well before "go"), jump to the test + // page and load the race words there. The words render blurred with a + // countdown overlay on top (RaceCountdownOverlay.tsx / test-ui.ts) so + // players can see the game but not read ahead or type early. + getSocket().on("room:countdown", () => { + void (async () => { + applyRaceConfigToLocalConfig(); + navigationEvent.dispatch({ url: "/", options: { force: true } }); + await TestLogic.restart({ noAnim: true, isRaceStart: true }); + })(); + }); + + // "go": force every player's timer to start at the exact same instant + // instead of waiting for each one's own first keystroke, which is what + // normally starts a test but would let players drift out of sync here. + getSocket().on("room:raceStarted", () => { + TestLogic.startTest(performance.now()); + }); +} + +function applyRaceConfigToLocalConfig(): void { + const room = getRoom(); + if (room === null) return; + const { config } = room; + + setConfig("mode", config.mode); + if (config.mode === "words") { + setConfig("words", config.words); + } else { + setConfig("time", config.time); + } + setConfig("language", config.language); + setConfig("punctuation", config.punctuation); + setConfig("numbers", config.numbers); + setConfig("difficulty", config.difficulty); + // funbox isn't supported in race mode configs, but a funbox left active + // from a previous solo session could still alter word selection and + // desync players, so make sure it's off for the duration of the race + setConfig("funbox", []); +} + +export async function createAndJoinRoom(config: RaceConfig): Promise { + await ensureConnected(); + + const response = await Ape.multiplayer.createRoom({ body: { config } }); + if (response.status !== 200) { + showErrorNotification(response.body.message); + return; + } + + await joinRoomByCode(response.body.data.roomCode); + navigationEvent.dispatch({ + url: `/multiplayer/${response.body.data.roomCode}`, + options: {}, + }); +} + +export async function joinRoomByCode(roomCode: string): Promise { + await ensureConnected(); + + return new Promise((resolve, reject) => { + getSocket().emit("room:join", { roomCode }, (ack) => { + if (ack.status === "ok") { + setRoom(ack.data); + resolve(); + } else { + setRoomError(ack.message); + showErrorNotification(ack.message); + reject(new Error(ack.message)); + } + }); + }); +} + +export function setReady(isReady: boolean): void { + const room = getRoom(); + if (room === null) return; + getSocket().emit("room:setReady", { roomCode: room.roomCode, isReady }); +} + +export function updateRaceConfig(config: RaceConfig): void { + const room = getRoom(); + if (room === null) return; + getSocket().emit("room:updateConfig", { roomCode: room.roomCode, config }); +} + +// Generous WPM ceiling used to size the word pool for "time" mode races, so +// a fast typist doesn't visibly exhaust it and wrap back to the start. +const TIME_MODE_WORDS_PER_SECOND_CEILING = 6; + +export async function startRace(): Promise { + const room = getRoom(); + if (room === null) return; + + applyRaceConfigToLocalConfig(); + + const { config } = room; + const targetWordCount = + config.mode === "words" + ? config.words + : config.time * TIME_MODE_WORDS_PER_SECOND_CEILING; + + const language = await JSONData.getLanguage(config.language); + + // generateWords() caps each call at 100 words regardless of how many were + // asked for, so batch multiple calls together for longer races + const words: string[] = []; + while (words.length < targetWordCount) { + const generated = await WordsGenerator.generateWords(language); + if (generated.words.length === 0) break; + words.push(...generated.words); + } + + const wordList = + config.mode === "words" ? words.slice(0, targetWordCount) : words; + + getSocket().emit("room:start", { + roomCode: room.roomCode, + wordList, + }); +} + +export function rematch(): void { + const room = getRoom(); + if (room === null) return; + getSocket().emit("room:rematch", { roomCode: room.roomCode }); +} + +export function leaveCurrentRoom(): void { + const room = getRoom(); + if (room !== null) { + getSocket().emit("room:leave", { roomCode: room.roomCode }); + } + leaveRoom(); +} diff --git a/frontend/src/ts/multiplayer/socket-client.ts b/frontend/src/ts/multiplayer/socket-client.ts new file mode 100644 index 000000000000..29f459859dbb --- /dev/null +++ b/frontend/src/ts/multiplayer/socket-client.ts @@ -0,0 +1,41 @@ +import { io, Socket } from "socket.io-client"; +import { envConfig } from "virtual:env-config"; +import { getIdToken } from "../firebase"; +import type { + ClientToServerEvents, + ServerToClientEvents, +} from "@monkeytype/schemas/multiplayer-events"; + +type MultiplayerSocket = Socket; + +let socket: MultiplayerSocket | null = null; + +export async function connect(): Promise { + if (socket !== null) { + return socket; + } + + socket = io(envConfig.backendUrl, { + autoConnect: true, + // function form: re-invoked on every (re)connection attempt, so a + // refreshed Firebase token is always used instead of a stale one. + auth: async (cb) => { + const token = await getIdToken(); + cb({ token: token ?? "" }); + }, + }); + + return socket; +} + +export function getSocket(): MultiplayerSocket { + if (socket === null) { + throw new Error("Multiplayer socket is not connected"); + } + return socket; +} + +export function disconnect(): void { + socket?.disconnect(); + socket = null; +} diff --git a/frontend/src/ts/pages/page.ts b/frontend/src/ts/pages/page.ts index 6a218dc08e35..5d218bd78a70 100644 --- a/frontend/src/ts/pages/page.ts +++ b/frontend/src/ts/pages/page.ts @@ -17,7 +17,8 @@ export type PageName = | "404" | "accountSettings" | "leaderboards" - | "friends"; + | "friends" + | "multiplayer"; type Options = { params?: Record; diff --git a/frontend/src/ts/states/multiplayer.ts b/frontend/src/ts/states/multiplayer.ts new file mode 100644 index 000000000000..8cac710ac172 --- /dev/null +++ b/frontend/src/ts/states/multiplayer.ts @@ -0,0 +1,119 @@ +import { createSignal } from "solid-js"; +import type { RaceRoom, RacePlayer } from "@monkeytype/schemas/multiplayer"; +import type { ServerToClientEvents } from "@monkeytype/schemas/multiplayer-events"; +import { getSocket } from "../multiplayer/socket-client"; +import { navigationEvent } from "../events/navigation"; + +export const [getRoom, setRoom] = createSignal(null); +export const [isRaceModeActive, setRaceModeActive] = createSignal(false); +export const [getRaceWordList, setRaceWordList] = createSignal( + null, +); +export const [getRaceCountdown, setRaceCountdown] = createSignal<{ + startsAtServerTime: number; + seconds: number; + wordList: string[]; +} | null>(null); +export const [getRaceResults, setRaceResults] = createSignal< + RacePlayer[] | null +>(null); +export const [getRoomError, setRoomError] = createSignal(null); + +export function resetRaceState(): void { + setRaceModeActive(false); + setRaceWordList(null); + setRaceCountdown(null); + setRaceResults(null); +} + +export function leaveRoom(): void { + setRoom(null); + resetRaceState(); +} + +let listenersInitialized = false; + +export function initMultiplayerListeners(): void { + if (listenersInitialized) return; + listenersInitialized = true; + + const socket = getSocket(); + + const on = ( + event: K, + handler: ServerToClientEvents[K], + ): void => { + socket.on(event, handler as never); + }; + + on("room:state", (room) => { + setRoom(room); + if (room.status === "lobby") { + setRaceResults(null); + } + }); + + on("room:playerJoined", (player) => { + setRoom((room) => { + if (room === null) return room; + const players = room.players.filter((it) => it.uid !== player.uid); + return { ...room, players: [...players, player] }; + }); + }); + + on("room:playerLeft", ({ uid }) => { + setRoom((room) => { + if (room === null) return room; + return { + ...room, + players: room.players.map((player) => + player.uid === uid ? { ...player, status: "disconnected" } : player, + ), + }; + }); + }); + + on("room:countdown", (payload) => { + setRaceCountdown(payload); + // set the word list and flip race mode on as soon as the countdown + // starts (not when it ends), so every client can navigate to the test + // page, warm up language data, and generate the (blurred) test words + // ahead of the actual "go" moment + setRaceWordList(payload.wordList); + setRaceModeActive(true); + }); + + on("room:raceStarted", () => { + // clears the countdown overlay/blur; race mode itself stays active + setRaceCountdown(null); + }); + + on("race:playerFinished", ({ uid, finalResult }) => { + setRoom((room) => { + if (room === null) return room; + return { + ...room, + players: room.players.map((player) => + player.uid === uid ? { ...player, finalResult } : player, + ), + }; + }); + }); + + on("race:results", ({ players }) => { + setRaceResults(players); + setRaceModeActive(false); + + const roomCode = getRoom()?.roomCode; + if (roomCode !== undefined) { + navigationEvent.dispatch({ + url: `/multiplayer/${roomCode}`, + options: { force: true }, + }); + } + }); + + on("room:error", ({ message }) => { + setRoomError(message); + }); +} diff --git a/frontend/src/ts/test/test-logic.ts b/frontend/src/ts/test/test-logic.ts index 7029d6810cfb..bb4b4e337004 100644 --- a/frontend/src/ts/test/test-logic.ts +++ b/frontend/src/ts/test/test-logic.ts @@ -61,6 +61,12 @@ import { getResultVisible, } from "../states/test"; import { restartTestEvent } from "../events/test"; +import { + isRaceModeActive, + setRaceModeActive, + getRoom, +} from "../states/multiplayer"; +import { getSocket } from "../multiplayer/socket-client"; import * as TestWords from "./test-words"; import * as WordsGenerator from "./words-generator"; import * as PageTransition from "../legacy-states/page-transition"; @@ -171,6 +177,9 @@ type RestartOptions = { practiseMissed?: boolean; noAnim?: boolean; isQuickRestart?: boolean; + /** internal: set only by the multiplayer race-start trigger itself, so the + * mid-race restart guard below doesn't also block the legitimate one */ + isRaceStart?: boolean; }; export async function restart(options = {} as RestartOptions): Promise { @@ -198,6 +207,17 @@ export async function restart(options = {} as RestartOptions): Promise { return; } + if (isRaceModeActive() && isTestActive() && !options.isRaceStart) { + showNoticeNotification( + "You can't restart or change the test during a multiplayer race.", + { + important: true, + }, + ); + options.event?.preventDefault(); + return; + } + if (isTestRestarting() || isResultCalculating()) { options.event?.preventDefault(); return; @@ -1041,7 +1061,23 @@ export async function finish(difficultyFailed = false): Promise { let savingResultPromise: ReturnType = Promise.resolve(null); const user = getAuthenticatedUser(); - if (user !== null) { + if (isRaceModeActive()) { + // Multiplayer race: never touch the real results/PB/XP pipeline. Report + // the final summary to the room over the socket instead of saving. + const room = getRoom(); + if (room !== null) { + getSocket().emit("race:finish", { + roomCode: room.roomCode, + wpm: completedEvent.wpm, + rawWpm: completedEvent.rawWpm, + acc: completedEvent.acc, + consistency: completedEvent.consistency, + charStats: completedEvent.charStats, + }); + } + setRaceModeActive(false); + dontSave = true; + } else if (user !== null) { // logged in if (dontSave) { void AnalyticsController.log("testCompletedInvalid"); diff --git a/frontend/src/ts/test/test-ui.ts b/frontend/src/ts/test/test-ui.ts index c2a7ff9e3dc6..a9c358c892ec 100644 --- a/frontend/src/ts/test/test-ui.ts +++ b/frontend/src/ts/test/test-ui.ts @@ -77,6 +77,7 @@ import { } from "./events/stats"; import * as ConnectionState from "../legacy-states/connection"; import * as TestInitFailed from "../elements/test-init-failed"; +import { getRaceCountdown } from "../states/multiplayer"; export const updateHintsPositionDebounced = Misc.debounceUntilResolved( updateHintsPosition, @@ -94,8 +95,10 @@ let lineTransition = false; // #words is still vanilla; the warning itself is Solid (OutOfFocusWarning.tsx). // show/hideOutOfFocus live in states/test so commandline needn't import test-ui. +// Also blurred during a multiplayer race countdown (RaceCountdownOverlay.tsx) +// so players can't read ahead before the synchronized start. createEffect(() => { - if (showOutOfFocusWarning()) { + if (showOutOfFocusWarning() || getRaceCountdown() !== null) { wordsEl.setStyle({ transition: "0.25s" })?.addClass("blurred"); } else { wordsEl.setStyle({ transition: "none" })?.removeClass("blurred"); diff --git a/frontend/src/ts/test/words-generator.ts b/frontend/src/ts/test/words-generator.ts index f2f0a0448729..db097b235cb8 100644 --- a/frontend/src/ts/test/words-generator.ts +++ b/frontend/src/ts/test/words-generator.ts @@ -25,6 +25,7 @@ import { import { WordGenError } from "../utils/word-gen-error"; import { showLoaderBar, hideLoaderBar } from "../states/loader-bar"; +import { isRaceModeActive, getRaceWordList } from "../states/multiplayer"; import { PolyglotWordset } from "./funbox/funbox-functions"; import { LanguageObject } from "@monkeytype/schemas/languages"; import { @@ -640,7 +641,9 @@ export async function generateWords( console.debug("Word order", wordOrder); let wordList = language.words; - if (Config.mode === "custom") { + if (isRaceModeActive() && getRaceWordList() !== null) { + wordList = getRaceWordList() as string[]; + } else if (Config.mode === "custom") { wordList = CustomText.getText(); } else if (Config.mode === "quote") { wordList = await getQuoteWordList(language, wordOrder); @@ -825,6 +828,10 @@ export async function getNextWord( if (Config.mode === "quote") { randomWord = currentWordset.nextWord(); + } else if (isRaceModeActive() && getRaceWordList() !== null) { + // race mode: every player must see the exact same sequence, so pull + // words off the shared list in order rather than sampling randomly + randomWord = currentWordset.nextWord(); } else if (Config.mode === "custom" && CustomText.getMode() === "repeat") { randomWord = currentWordset.nextWord(); } else if ( diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index a2b7c2fd1852..df73d26d0814 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -13,6 +13,7 @@ import { usersContract } from "./users"; import { quotesContract } from "./quotes"; import { webhooksContract } from "./webhooks"; import { connectionsContract } from "./connections"; +import { multiplayerContract } from "./multiplayer"; const c = initContract(); @@ -31,6 +32,7 @@ export const contract = c.router({ quotes: quotesContract, webhooks: webhooksContract, connections: connectionsContract, + multiplayer: multiplayerContract, }); /** diff --git a/packages/contracts/src/multiplayer.ts b/packages/contracts/src/multiplayer.ts new file mode 100644 index 000000000000..05832442139b --- /dev/null +++ b/packages/contracts/src/multiplayer.ts @@ -0,0 +1,70 @@ +import { initContract } from "@ts-rest/core"; +import { z } from "zod"; +import { + CommonResponses, + meta, + MonkeyClientError, + responseWithData, +} from "./util/api"; +import { + RaceConfigSchema, + RaceRoomSchema, + RoomCodeSchema, +} from "@monkeytype/schemas/multiplayer"; + +export const CreateRoomRequestSchema = z.object({ + config: RaceConfigSchema, +}); +export type CreateRoomRequest = z.infer; + +export const CreateRoomResponseSchema = responseWithData(RaceRoomSchema); +export type CreateRoomResponse = z.infer; + +export const GetRoomPathSchema = z.object({ + roomCode: RoomCodeSchema, +}); +export type GetRoomPath = z.infer; + +export const GetRoomResponseSchema = responseWithData(RaceRoomSchema); +export type GetRoomResponse = z.infer; + +const c = initContract(); +export const multiplayerContract = c.router( + { + createRoom: { + summary: "create a multiplayer race room", + description: "Creates a new room and makes the caller its host", + method: "POST", + path: "/room", + body: CreateRoomRequestSchema.strict(), + responses: { + 200: CreateRoomResponseSchema, + }, + metadata: meta({ + rateLimit: "multiplayerCreateRoom", + }), + }, + getRoom: { + summary: "get a multiplayer race room", + description: "Gets a room by its shareable code, used before joining", + method: "GET", + path: "/room/:roomCode", + pathParams: GetRoomPathSchema, + responses: { + 200: GetRoomResponseSchema, + 404: MonkeyClientError.describe("Room not found"), + }, + metadata: meta({ + rateLimit: "multiplayerGetRoom", + }), + }, + }, + { + pathPrefix: "/multiplayer", + strictStatusCodes: true, + metadata: meta({ + openApiTags: "multiplayer", + }), + commonResponses: CommonResponses, + }, +); diff --git a/packages/contracts/src/rate-limit/index.ts b/packages/contracts/src/rate-limit/index.ts index 26bd7ae4aa2f..acba56bf01fb 100644 --- a/packages/contracts/src/rate-limit/index.ts +++ b/packages/contracts/src/rate-limit/index.ts @@ -386,6 +386,17 @@ export const limits = { window: "hour", max: 60, }, + + // Multiplayer Routing + multiplayerCreateRoom: { + window: "minute", + max: 5, + }, + + multiplayerGetRoom: { + window: "minute", + max: 60, + }, } satisfies Record; export type RateLimiterId = keyof typeof limits; diff --git a/packages/contracts/src/util/api.ts b/packages/contracts/src/util/api.ts index a083ebdc743b..9983d95ed759 100644 --- a/packages/contracts/src/util/api.ts +++ b/packages/contracts/src/util/api.ts @@ -16,7 +16,8 @@ export type OpenApiTag = | "users" | "quotes" | "webhooks" - | "connections"; + | "connections" + | "multiplayer"; export type PermissionId = | "quoteMod" diff --git a/packages/schemas/src/multiplayer-events.ts b/packages/schemas/src/multiplayer-events.ts new file mode 100644 index 000000000000..8c26e8e589b5 --- /dev/null +++ b/packages/schemas/src/multiplayer-events.ts @@ -0,0 +1,53 @@ +import type { + RaceConfig, + RaceRoom, + RacePlayer, + RaceFinalResult, +} from "./multiplayer"; + +export type AckResult = + | { status: "ok"; data: T } + | { status: "error"; message: string }; + +export type RaceFinishPayload = { + roomCode: string; +} & Omit; + +/** Events sent from the browser to the server. */ +export type ClientToServerEvents = { + "room:join": ( + payload: { roomCode: string }, + ack: (res: AckResult) => void, + ) => void; + "room:leave": (payload: { roomCode: string }) => void; + "room:updateConfig": (payload: { + roomCode: string; + config: RaceConfig; + }) => void; + "room:setReady": (payload: { roomCode: string; isReady: boolean }) => void; + "room:start": (payload: { roomCode: string; wordList: string[] }) => void; + "room:rematch": (payload: { roomCode: string }) => void; + "race:finish": (payload: RaceFinishPayload) => void; +}; + +/** Events sent from the server to the browser. */ +export type ServerToClientEvents = { + "room:state": (room: RaceRoom) => void; + "room:playerJoined": (player: RacePlayer) => void; + "room:playerLeft": (payload: { uid: string }) => void; + "room:countdown": (payload: { + startsAtServerTime: number; + seconds: number; + wordList: string[]; + }) => void; + "room:raceStarted": (payload: { + wordList: string[]; + raceStartedAt: number; + }) => void; + "race:playerFinished": (payload: { + uid: string; + finalResult: RaceFinalResult; + }) => void; + "race:results": (payload: { players: RacePlayer[] }) => void; + "room:error": (payload: { message: string; code: string }) => void; +}; diff --git a/packages/schemas/src/multiplayer.ts b/packages/schemas/src/multiplayer.ts new file mode 100644 index 000000000000..2eed287d10be --- /dev/null +++ b/packages/schemas/src/multiplayer.ts @@ -0,0 +1,62 @@ +import { z } from "zod"; +import { IdSchema, PercentageSchema, WpmSchema, token } from "./util"; +import { LanguageSchema } from "./languages"; +import { DifficultySchema } from "./shared"; +import { CharStatsSchema } from "./results"; + +export const RoomCodeSchema = token().length(6); +export type RoomCode = z.infer; + +export const RaceConfigSchema = z.object({ + mode: z.enum(["words", "time"]), + words: z.number().int().min(10).max(200), + time: z.number().int().min(15).max(300), + language: LanguageSchema, + punctuation: z.boolean(), + numbers: z.boolean(), + difficulty: DifficultySchema, +}); +export type RaceConfig = z.infer; + +export const RoomStatusSchema = z.enum([ + "lobby", + "countdown", + "racing", + "finished", +]); +export type RoomStatus = z.infer; + +export const RacePlayerStatusSchema = z.enum(["connected", "disconnected"]); +export type RacePlayerStatus = z.infer; + +export const RaceFinalResultSchema = z.object({ + wpm: WpmSchema, + rawWpm: WpmSchema, + acc: PercentageSchema, + consistency: PercentageSchema, + charStats: CharStatsSchema, + finishedAt: z.number().int().nonnegative(), +}); +export type RaceFinalResult = z.infer; + +export const RacePlayerSchema = z.object({ + uid: IdSchema, + name: z.string(), + isHost: z.boolean(), + isReady: z.boolean(), + status: RacePlayerStatusSchema, + finalResult: RaceFinalResultSchema.nullable(), +}); +export type RacePlayer = z.infer; + +export const RaceRoomSchema = z.object({ + roomCode: RoomCodeSchema, + hostUid: IdSchema, + status: RoomStatusSchema, + config: RaceConfigSchema, + wordList: z.array(z.string()).nullable(), + players: z.array(RacePlayerSchema), + createdAt: z.number().int().nonnegative(), + raceStartedAt: z.number().int().nonnegative().nullable(), +}); +export type RaceRoom = z.infer; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d2d592c8bc31..aff73abd4d5f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -170,6 +170,9 @@ importers: simple-git: specifier: 3.36.0 version: 3.36.0 + socket.io: + specifier: 4.8.3 + version: 4.8.3 string-similarity: specifier: 4.0.4 version: 4.0.4 @@ -402,6 +405,9 @@ importers: slim-select: specifier: 2.9.2 version: 2.9.2 + socket.io-client: + specifier: 4.8.3 + version: 4.8.3 stemmer: specifier: 2.0.1 version: 2.0.1 @@ -677,7 +683,7 @@ importers: version: 7.0.2 vitest: specifier: 4.1.0 - version: 4.1.0(@types/node@24.9.1)(happy-dom@20.8.9)(jsdom@27.4.0)(vite@8.0.5(@emnapi/core@1.9.0)(@emnapi/runtime@1.9.0)(@types/node@24.9.1)(esbuild@0.25.0)(jiti@2.7.0)(sass@1.98.0)(terser@5.49.0)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.0(@types/node@24.9.1)(happy-dom@20.8.9)(jsdom@27.4.0)(vite@8.0.5(@emnapi/core@1.9.0)(@emnapi/runtime@1.9.0)(@types/node@24.9.1)(esbuild@0.27.7)(jiti@2.7.0)(sass@1.98.0)(terser@5.49.0)(tsx@4.21.0)(yaml@2.8.3)) packages/contracts: dependencies: @@ -825,7 +831,7 @@ importers: dependencies: tsup: specifier: 8.4.0 - version: 8.4.0(jiti@2.7.0)(postcss@8.5.8)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3) + version: 8.4.0(jiti@2.7.0)(postcss@8.5.8)(tsx@4.21.0)(typescript@7.0.2)(yaml@2.8.3) devDependencies: '@monkeytype/typescript-config': specifier: workspace:* @@ -4148,6 +4154,9 @@ packages: deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. hasBin: true + '@socket.io/component-emitter@3.1.2': + resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==} + '@solid-devtools/debugger@0.28.1': resolution: {integrity: sha512-6qIUI6VYkXoRnL8oF5bvh2KgH71qlJ18hNw/mwSyY6v48eb80ZR48/5PDXufUa3q+MBSuYa1uqTMwLewpay9eg==} peerDependencies: @@ -5782,6 +5791,10 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + base64id@2.0.0: + resolution: {integrity: sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==} + engines: {node: ^4.5.0 || >= 5.9} + baseline-browser-mapping@2.11.12: resolution: {integrity: sha512-r7WnVImvVCeFpf2DOXfy41aPWzeNg3H/A2X4dKmy1QL0MSyyk/e7z8ihJ3N6Nn2PsdhkVlqnEfnUE4a05P2aTA==} engines: {node: '>=6.0.0'} @@ -6886,6 +6899,17 @@ packages: end-of-stream@1.4.4: resolution: {integrity: sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==} + engine.io-client@6.6.6: + resolution: {integrity: sha512-iY6QdftLQ9pyiPoX082bpf/u1UewnOaJrtJIF9T0++QB34lZrj0uP+Q/bj8AlUsAxqhnkTV2BS8SBZSxOmoV5Q==} + + engine.io-parser@5.2.3: + resolution: {integrity: sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==} + engines: {node: '>=10.0.0'} + + engine.io@6.6.9: + resolution: {integrity: sha512-clKkw4C7nJ22mGgoVcCg6V/W/TxdNyIOTr89k2ONZu81qqkddPFDF0LXcbAwhzPD8DjkiRCjzuiO6Y+fkpD4vg==} + engines: {node: '>=10.2.0'} + enhanced-resolve@5.17.1: resolution: {integrity: sha512-LMHl3dXhTcfv8gM4kEzIUeTQ+7fpdA0l2tUf34BddXPkz2A5xJ5L/Pchd5BL6rdccM9QGvu0sWZzK1Z1t4wwyg==} engines: {node: '>=10.13.0'} @@ -10657,6 +10681,21 @@ packages: resolution: {integrity: sha512-RQsvleCbF8cVHEv+xuDGaA4pOizFqJ0GgjtMSRo6oP8pnN7WsigHgVGey6aILRBKv4W2YOMHLqbKdnB6hpB9fw==} engines: {node: '>=20.0.0'} + socket.io-adapter@2.5.8: + resolution: {integrity: sha512-6Oy52pbg+kvdCVvjcN+FnY7BvxZ7cIHNScbvztT/It5d0vbwoJoVZmF2gjJmnV0/4WlXRfG15zc45ySk9Ah8bw==} + + socket.io-client@4.8.3: + resolution: {integrity: sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==} + engines: {node: '>=10.0.0'} + + socket.io-parser@4.2.7: + resolution: {integrity: sha512-IH/iSeO9T6gz1KkFleGDWkG9N3dl4jXVYUtMhIqH10Md0ttMer8nUNWiP1DKuNrybD2xBrixLJdCC9J6ECoYkg==} + engines: {node: '>=10.0.0'} + + socket.io@4.8.3: + resolution: {integrity: sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==} + engines: {node: '>=10.2.0'} + socks-proxy-agent@8.0.4: resolution: {integrity: sha512-GNAq/eg8Udq2x0eNiFkr9gRg5bA7PXEWagQdeRX4cPSG+X/8V38v637gim9bjFptMk1QWsCTr0ttrJEiXbNnRw==} engines: {node: '>= 14'} @@ -11427,11 +11466,6 @@ packages: engines: {node: '>=14.17'} hasBin: true - typescript@6.0.2: - resolution: {integrity: sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==} - engines: {node: '>=14.17'} - hasBin: true - typescript@7.0.2: resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==} engines: {node: '>=16.20.0'} @@ -12078,6 +12112,10 @@ packages: xmlchars@2.2.0: resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + xmlhttprequest-ssl@2.1.2: + resolution: {integrity: sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==} + engines: {node: '>=0.4.0'} + xtend@4.0.2: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} @@ -15442,6 +15480,8 @@ snapshots: ignore: 5.3.2 p-map: 4.0.0 + '@socket.io/component-emitter@3.1.2': {} + '@solid-devtools/debugger@0.28.1(solid-js@1.9.13)': dependencies: '@nothing-but/utils': 0.17.0 @@ -16749,14 +16789,6 @@ snapshots: optionalDependencies: vite: 7.3.2(@types/node@24.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.98.0)(terser@5.49.0)(tsx@4.21.0)(yaml@2.8.3) - '@vitest/mocker@4.1.0(vite@8.0.5(@emnapi/core@1.9.0)(@emnapi/runtime@1.9.0)(@types/node@24.9.1)(esbuild@0.25.0)(jiti@2.7.0)(sass@1.98.0)(terser@5.49.0)(tsx@4.21.0)(yaml@2.8.3))': - dependencies: - '@vitest/spy': 4.1.0 - estree-walker: 3.0.3 - magic-string: 0.30.21 - optionalDependencies: - vite: 8.0.5(@emnapi/core@1.9.0)(@emnapi/runtime@1.9.0)(@types/node@24.9.1)(esbuild@0.25.0)(jiti@2.7.0)(sass@1.98.0)(terser@5.49.0)(tsx@4.21.0)(yaml@2.8.3) - '@vitest/mocker@4.1.0(vite@8.0.5(@emnapi/core@1.9.0)(@emnapi/runtime@1.9.0)(@types/node@24.9.1)(esbuild@0.27.7)(jiti@2.7.0)(sass@1.70.0)(terser@5.49.0)(tsx@4.21.0)(yaml@2.8.3))': dependencies: '@vitest/spy': 4.1.0 @@ -17241,6 +17273,8 @@ snapshots: base64-js@1.5.1: {} + base64id@2.0.0: {} + baseline-browser-mapping@2.11.12: {} baseline-browser-mapping@2.9.11: {} @@ -18412,6 +18446,37 @@ snapshots: dependencies: once: 1.4.0 + engine.io-client@6.6.6: + dependencies: + '@socket.io/component-emitter': 3.1.2 + debug: 4.4.3 + engine.io-parser: 5.2.3 + ws: 8.21.0 + xmlhttprequest-ssl: 2.1.2 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + engine.io-parser@5.2.3: {} + + engine.io@6.6.9: + dependencies: + '@types/cors': 2.8.19 + '@types/node': 24.9.1 + '@types/ws': 8.18.1 + accepts: 1.3.8 + base64id: 2.0.0 + cookie: 0.7.2 + cors: 2.8.6 + debug: 4.4.3 + engine.io-parser: 5.2.3 + ws: 8.21.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + enhanced-resolve@5.17.1: dependencies: graceful-fs: 4.2.11 @@ -23044,6 +23109,47 @@ snapshots: smob@1.6.2: {} + socket.io-adapter@2.5.8: + dependencies: + debug: 4.4.3 + ws: 8.21.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + socket.io-client@4.8.3: + dependencies: + '@socket.io/component-emitter': 3.1.2 + debug: 4.4.3 + engine.io-client: 6.6.6 + socket.io-parser: 4.2.7 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + socket.io-parser@4.2.7: + dependencies: + '@socket.io/component-emitter': 3.1.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + socket.io@4.8.3: + dependencies: + accepts: 1.3.8 + base64id: 2.0.0 + cors: 2.8.6 + debug: 4.4.3 + engine.io: 6.6.9 + socket.io-adapter: 2.5.8 + socket.io-parser: 4.2.7 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + socks-proxy-agent@8.0.4: dependencies: agent-base: 7.1.1 @@ -23875,33 +23981,6 @@ snapshots: tsscmp@1.0.6: {} - tsup@8.4.0(jiti@2.7.0)(postcss@8.5.8)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3): - dependencies: - bundle-require: 5.1.0(esbuild@0.25.0) - cac: 6.7.14 - chokidar: 4.0.3 - consola: 3.4.0 - debug: 4.4.1 - esbuild: 0.25.0 - joycon: 3.1.1 - picocolors: 1.1.1 - postcss-load-config: 6.0.1(jiti@2.7.0)(postcss@8.5.8)(tsx@4.21.0)(yaml@2.8.3) - resolve-from: 5.0.0 - rollup: 4.40.0 - source-map: 0.8.0-beta.0 - sucrase: 3.35.0 - tinyexec: 0.3.2 - tinyglobby: 0.2.13 - tree-kill: 1.2.2 - optionalDependencies: - postcss: 8.5.8 - typescript: 6.0.2 - transitivePeerDependencies: - - jiti - - supports-color - - tsx - - yaml - tsup@8.4.0(jiti@2.7.0)(postcss@8.5.8)(tsx@4.21.0)(typescript@7.0.2)(yaml@2.8.3): dependencies: bundle-require: 5.1.0(esbuild@0.25.0) @@ -24011,9 +24090,6 @@ snapshots: typescript@5.9.3: {} - typescript@6.0.2: - optional: true - typescript@7.0.2: optionalDependencies: '@typescript/typescript-aix-ppc64': 7.0.2 @@ -24312,26 +24388,6 @@ snapshots: tsx: 4.21.0 yaml: 2.8.3 - vite@8.0.5(@emnapi/core@1.9.0)(@emnapi/runtime@1.9.0)(@types/node@24.9.1)(esbuild@0.25.0)(jiti@2.7.0)(sass@1.98.0)(terser@5.49.0)(tsx@4.21.0)(yaml@2.8.3): - dependencies: - lightningcss: 1.32.0 - picomatch: 4.0.4 - postcss: 8.5.8 - rolldown: 1.0.0-rc.12(@emnapi/core@1.9.0)(@emnapi/runtime@1.9.0) - tinyglobby: 0.2.15 - optionalDependencies: - '@types/node': 24.9.1 - esbuild: 0.25.0 - fsevents: 2.3.3 - jiti: 2.7.0 - sass: 1.98.0 - terser: 5.49.0 - tsx: 4.21.0 - yaml: 2.8.3 - transitivePeerDependencies: - - '@emnapi/core' - - '@emnapi/runtime' - vite@8.0.5(@emnapi/core@1.9.0)(@emnapi/runtime@1.9.0)(@types/node@24.9.1)(esbuild@0.27.7)(jiti@2.7.0)(sass@1.70.0)(terser@5.49.0)(tsx@4.21.0)(yaml@2.8.3): dependencies: lightningcss: 1.32.0 @@ -24460,35 +24516,6 @@ snapshots: transitivePeerDependencies: - msw - vitest@4.1.0(@types/node@24.9.1)(happy-dom@20.8.9)(jsdom@27.4.0)(vite@8.0.5(@emnapi/core@1.9.0)(@emnapi/runtime@1.9.0)(@types/node@24.9.1)(esbuild@0.25.0)(jiti@2.7.0)(sass@1.98.0)(terser@5.49.0)(tsx@4.21.0)(yaml@2.8.3)): - dependencies: - '@vitest/expect': 4.1.0 - '@vitest/mocker': 4.1.0(vite@8.0.5(@emnapi/core@1.9.0)(@emnapi/runtime@1.9.0)(@types/node@24.9.1)(esbuild@0.25.0)(jiti@2.7.0)(sass@1.98.0)(terser@5.49.0)(tsx@4.21.0)(yaml@2.8.3)) - '@vitest/pretty-format': 4.1.0 - '@vitest/runner': 4.1.0 - '@vitest/snapshot': 4.1.0 - '@vitest/spy': 4.1.0 - '@vitest/utils': 4.1.0 - es-module-lexer: 2.0.0 - expect-type: 1.3.0 - magic-string: 0.30.21 - obug: 2.1.1 - pathe: 2.0.3 - picomatch: 4.0.4 - std-env: 4.1.0 - tinybench: 2.9.0 - tinyexec: 1.1.2 - tinyglobby: 0.2.17 - tinyrainbow: 3.1.0 - vite: 8.0.5(@emnapi/core@1.9.0)(@emnapi/runtime@1.9.0)(@types/node@24.9.1)(esbuild@0.25.0)(jiti@2.7.0)(sass@1.98.0)(terser@5.49.0)(tsx@4.21.0)(yaml@2.8.3) - why-is-node-running: 2.3.0 - optionalDependencies: - '@types/node': 24.9.1 - happy-dom: 20.8.9 - jsdom: 27.4.0 - transitivePeerDependencies: - - msw - vitest@4.1.0(@types/node@24.9.1)(happy-dom@20.8.9)(jsdom@27.4.0)(vite@8.0.5(@emnapi/core@1.9.0)(@emnapi/runtime@1.9.0)(@types/node@24.9.1)(esbuild@0.27.7)(jiti@2.7.0)(sass@1.70.0)(terser@5.49.0)(tsx@4.21.0)(yaml@2.8.3)): dependencies: '@vitest/expect': 4.1.0 @@ -24881,6 +24908,8 @@ snapshots: xmlchars@2.2.0: {} + xmlhttprequest-ssl@2.1.2: {} + xtend@4.0.2: {} y18n@5.0.8: {} From 837252c7891bea23fa52e638d27ed7a71e034866 Mon Sep 17 00:00:00 2001 From: Franklin Jetty Johnson Date: Wed, 12 Aug 2026 16:33:12 +0530 Subject: [PATCH 2/2] chore: retrigger CI (@jhonsfranky17)