diff --git a/api/prisma/migrations/20251220170125_add_local_timer_fields/migration.sql b/api/prisma/migrations/20251220170125_add_local_timer_fields/migration.sql new file mode 100644 index 00000000..01dcd6b5 --- /dev/null +++ b/api/prisma/migrations/20251220170125_add_local_timer_fields/migration.sql @@ -0,0 +1,10 @@ +-- CreateEnum +CREATE TYPE "RaceHandler" AS ENUM ('LOCAL', 'RACETIME'); + +-- AlterTable +ALTER TABLE "Player" ADD COLUMN "finishedAt" TIMESTAMP(3); + +-- AlterTable +ALTER TABLE "Room" ADD COLUMN "finishedAt" TIMESTAMP(3), +ADD COLUMN "raceHandler" "RaceHandler", +ADD COLUMN "startedAt" TIMESTAMP(3); diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index 26db060a..4dfd9491 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -146,19 +146,23 @@ model Room { variantId String? exploration Boolean @default(false) explorationStart String? + raceHandler RaceHandler? + startedAt DateTime? + finishedAt DateTime? } model Player { - id String @id @unique @default(cuid()) - key String - user User? @relation(fields: [userId], references: [id]) - room Room @relation(fields: [roomId], references: [id]) - nickname String - color String @default("blue") - spectator Boolean - monitor Boolean @default(false) - roomId String - userId String? + id String @id @unique @default(cuid()) + key String + user User? @relation(fields: [userId], references: [id]) + room Room @relation(fields: [roomId], references: [id]) + nickname String + color String @default("blue") + spectator Boolean + monitor Boolean @default(false) + roomId String + userId String? + finishedAt DateTime? @@unique([key, roomId]) } @@ -229,3 +233,8 @@ enum GenerationGlobalAdjustments { SYNERGIZE // add an additional copy of each goal sharing a type to increase final synergy BOARD_TYPE_MAX // enforce a maximum number of goals of a specific type in the board } + +enum RaceHandler { + LOCAL + RACETIME +} diff --git a/api/src/auth/RoomAuth.ts b/api/src/auth/RoomAuth.ts index 67b7378d..16f1eb35 100644 --- a/api/src/auth/RoomAuth.ts +++ b/api/src/auth/RoomAuth.ts @@ -75,6 +75,10 @@ export const hasPermission = ( return payload.isMonitor; case 'changeColor': return !payload.isSpectating; + case 'startTimer': + case 'changeRaceHandler': + case 'resetTimer': + return payload.isMonitor; default: return true; } diff --git a/api/src/core/Player.ts b/api/src/core/Player.ts index a870eb82..f02b9bac 100644 --- a/api/src/core/Player.ts +++ b/api/src/core/Player.ts @@ -6,14 +6,8 @@ import { } from '@playbingo/types'; import { OPEN, WebSocket } from 'ws'; import { RoomTokenPayload } from '../auth/RoomAuth'; -import { getAccessToken } from '../lib/RacetimeConnector'; -import RaceHandler from './integration/races/RaceHandler'; +import { computeRevealedMask, rowColToMask } from '../util/RoomUtils'; import Room from './Room'; -import { - computeRevealedMask, - rowColToBitIndex, - rowColToMask, -} from '../util/RoomUtils'; /** * Represents a player connected to a room. While largely just a data class, this @@ -61,8 +55,7 @@ export default class Player { * is authorized for the connection */ connections: Map; - raceHandler: RaceHandler; - raceId: string; + finishedAt?: string; constructor( room: Room, @@ -87,9 +80,6 @@ export default class Player { this.exploredGoals = 0n; this.connections = new Map(); - - this.raceHandler = room.raceHandler; - this.raceId = ''; } doesTokenMatch(token: RoomTokenPayload) { @@ -153,7 +143,7 @@ export default class Player { * @returns Client representation of this player's data */ toClientData(): PlayerClientData { - const raceUser = this.raceHandler.getPlayer(this.raceId); + const raceUser = this.room.raceHandler.getPlayer(this); return { id: this.id, nickname: this.nickname, @@ -303,48 +293,19 @@ export default class Player { //#endregion //#region Races - private async tryRaceAction( - action: (token: string) => Promise, - failMsg: string, - ) { - if (this.userId) { - const token = await getAccessToken(this.userId); - if (token) { - return action(token); - } else { - this.room.logInfo(`${failMsg} - failed to generate token`); - return false; - } - } else { - this.room.logInfo(`${failMsg} - player is anonymous`); - return false; - } - } async joinRace() { - return this.tryRaceAction( - this.raceHandler.joinPlayer.bind(this.raceHandler), - 'Unable to join race room', - ); + return this.room.raceHandler.joinPlayer(this); } async leaveRace() { - return this.tryRaceAction( - this.raceHandler.leavePlayer.bind(this.raceHandler), - 'Unable to leave race room', - ); + return this.room.raceHandler.leavePlayer(this); } async ready() { - return this.tryRaceAction( - this.raceHandler.readyPlayer.bind(this.raceHandler), - 'Unable to ready in race room', - ); + return this.room.raceHandler.readyPlayer(this); } async unready() { - return this.tryRaceAction( - this.raceHandler.unreadyPlayer.bind(this.raceHandler), - 'Unable to unready in race room', - ); + return this.room.raceHandler.unreadyPlayer(this); } //#endregion } diff --git a/api/src/core/Room.ts b/api/src/core/Room.ts index 9e7c2ac2..cbe1af97 100644 --- a/api/src/core/Room.ts +++ b/api/src/core/Room.ts @@ -1,5 +1,7 @@ +import { GeneratorSettings } from '@playbingo/shared'; import { ChangeColorAction, + ChangeRaceHandlerAction, ChatAction, ChatMessage, JoinAction, @@ -14,7 +16,7 @@ import { import { BingoMode } from '@prisma/client'; import { WebSocket } from 'ws'; import { roomCleanupInactive } from '../Environment'; -import { logError, logInfo, logWarn } from '../Logger'; +import { logDebug, logError, logInfo, logWarn } from '../Logger'; import { invalidateToken, Permissions, @@ -29,6 +31,7 @@ import { addUnmarkAction, createUpdatePlayer, setRoomBoard, + updateRaceHandler, } from '../database/Rooms'; import { isStaff } from '../database/Users'; import { @@ -38,7 +41,7 @@ import { useTypedRandom, } from '../database/games/Games'; import { getCategories } from '../database/games/GoalCategories'; -import { goalsForGame, goalsForGameFull } from '../database/games/Goals'; +import { goalsForGameFull } from '../database/games/Goals'; import { shuffle } from '../util/Array'; import { computeLineMasks, @@ -55,8 +58,9 @@ import { } from './generation/GeneratorCore'; import { generateFullRandom, generateRandomTyped } from './generation/Random'; import { generateSRLv5 } from './generation/SRLv5'; -import RaceHandler, { RaceData } from './integration/races/RacetimeHandler'; -import { GeneratorSettings } from '@playbingo/shared'; +import RacetimeHandler, { RaceData } from './integration/races/RacetimeHandler'; +import LocalTimer from './integration/races/LocalTimer'; +import RaceHandler from './integration/races/RaceHandler'; export enum BoardGenerationMode { RANDOM = 'Random', @@ -156,7 +160,11 @@ export default class Room { this.lastGenerationMode = { mode: BoardGenerationMode.RANDOM }; this.racetimeEligible = !!racetimeEligible; - this.raceHandler = new RaceHandler(this); + if (this.racetimeEligible) { + this.raceHandler = new RacetimeHandler(this); + } else { + this.raceHandler = new LocalTimer(this); + } this.board = []; @@ -426,16 +434,23 @@ export default class Room { name: this.name, gameSlug: this.gameSlug, newGenerator: this.newGenerator, - racetimeConnection: { - gameActive: this.racetimeEligible, - url: this.raceHandler.url, - startDelay: this.raceHandler.data?.start_delay, - started: this.raceHandler.data?.started_at ?? undefined, - ended: this.raceHandler.data?.ended_at ?? undefined, - status: this.raceHandler.data?.status.verbose_value, - }, + racetimeConnection: this.raceHandler + ? 'url' in this.raceHandler + ? { + gameActive: this.racetimeEligible, + url: (this.raceHandler as RacetimeHandler).url, + startDelay: (this.raceHandler as RacetimeHandler) + .data?.start_delay, + status: (this.raceHandler as RacetimeHandler).data + ?.status.verbose_value, + } + : undefined + : { gameActive: this.racetimeEligible, url: undefined }, mode: getModeString(this.bingoMode, this.lineCount), variant: this.variantName, + startedAt: this.raceHandler?.getStartTime(), + finishedAt: this.raceHandler?.getEndTime(), + raceHandler: this.raceHandler?.key(), }, players: this.getPlayers(), }; @@ -589,6 +604,32 @@ export default class Room { } } + handleStartTimer() { + this.raceHandler?.startTimer(); + this.sendRoomData(); + } + + handleChangeRaceHandler(action: ChangeRaceHandlerAction) { + if (this.raceHandler) { + this.raceHandler.disconnect(); + } + switch (action.raceHandler) { + case 'local': + this.raceHandler = new LocalTimer(this); + break; + case 'racetime': + this.raceHandler = new RacetimeHandler(this); + break; + } + this.sendRoomData(); + updateRaceHandler(this.id, this.raceHandler.key()).then(); + } + + handleResetTimer() { + this.raceHandler?.resetTimer(); + this.sendRoomData(); + } + handleSocketClose(ws: WebSocket) { let player: Player | undefined; for (const p of this.players.values()) { @@ -626,11 +667,12 @@ export default class Room { newGenerator: this.newGenerator, mode: getModeString(this.bingoMode, this.lineCount), variant: this.variantName, + raceHandler: this.raceHandler?.key(), }, }); this.sendChat(`Racetime.gg room created ${url}`); this.raceHandler.connect(url); - this.raceHandler.connectWebsocket(); + (this.raceHandler as RacetimeHandler).connectWebsocket(); } handleRacetimeRoomDisconnected() { @@ -648,6 +690,7 @@ export default class Room { newGenerator: this.newGenerator, mode: getModeString(this.bingoMode, this.lineCount), variant: this.variantName, + raceHandler: this.raceHandler?.key(), }, }); } @@ -657,22 +700,7 @@ export default class Room { if (!player) { return null; } - this.sendChat([ - { - contents: player.nickname, - color: player.color, - }, - ' has revealed the card.', - ]); - player.sendMessage({ - action: 'syncBoard', - board: { - hidden: false, - board: this.board, - width: this.board[0].length, - height: this.board.length, - }, - }); + this.revealCardForPlayer(player); } //#endregion @@ -724,13 +752,42 @@ export default class Room { players: this.getPlayers(), racetimeConnection: { gameActive: this.racetimeEligible, - url: this.raceHandler.url, + url: (this.raceHandler as RacetimeHandler).url, startDelay: data.start_delay ?? undefined, - started: data.started_at ?? undefined, - ended: data.ended_at ?? undefined, status: data.status.verbose_value, }, }); + this.sendRoomData(); + } + + sendRoomData() { + this.sendServerMessage({ + action: 'updateRoomData', + roomData: { + game: this.game, + slug: this.slug, + name: this.name, + gameSlug: this.gameSlug, + racetimeConnection: + 'url' in this.raceHandler + ? { + gameActive: this.racetimeEligible, + url: (this.raceHandler as RacetimeHandler).url, + startDelay: + (this.raceHandler as RacetimeHandler).data + ?.start_delay ?? undefined, + status: (this.raceHandler as RacetimeHandler).data + ?.status.verbose_value, + } + : undefined, + newGenerator: this.newGenerator, + mode: getModeString(this.bingoMode, this.lineCount), + variant: this.variantName, + startedAt: this.raceHandler?.getStartTime(), + finishedAt: this.raceHandler?.getEndTime(), + raceHandler: this.raceHandler?.key(), + }, + }); } private sendServerMessage( @@ -764,6 +821,7 @@ export default class Room { ' has achieved lockout!', ]); player.goalComplete = true; + this.raceHandler?.playerFinished(player); } if (player.goalComplete && player.goalCount < goalsNeeded) { this.sendChat([ @@ -774,6 +832,7 @@ export default class Room { ' no longer has lockout.', ]); player.goalComplete = false; + this.raceHandler?.playerUnfinshed(player); } } else { if (this.bingoMode === BingoMode.LINES) { @@ -796,6 +855,7 @@ export default class Room { !player.goalComplete ) { player.goalComplete = true; + this.raceHandler?.playerFinished(player).then(); this.sendChat([ { contents: player.nickname, @@ -808,6 +868,7 @@ export default class Room { player.goalComplete ) { player.goalComplete = false; + this.raceHandler?.playerUnfinshed(player); this.sendChat([ { contents: player.nickname, @@ -823,6 +884,7 @@ export default class Room { ); if (complete && !player.goalComplete) { player.goalComplete = true; + this.raceHandler?.playerFinished(player); this.sendChat([ { contents: player.nickname, @@ -832,6 +894,7 @@ export default class Room { ]); } else if (!complete && player.goalComplete) { player.goalComplete = false; + this.raceHandler?.playerUnfinshed(player); this.sendChat([ { contents: player.nickname, @@ -850,6 +913,15 @@ export default class Room { } }); this.completed = allComplete; + if (this.completed) { + this.raceHandler?.allPlayersFinished(); + this.sendRoomData(); + } else { + if (this.raceHandler?.getEndTime()) { + this.raceHandler?.allPlayersNotFinished(); + this.sendRoomData(); + } + } } /** @@ -904,7 +976,7 @@ export default class Room { //#region Racetime Integration async connectRacetimeWebSocket() { - this.raceHandler.connectWebsocket(); + (this.raceHandler as RacetimeHandler).connectWebsocket(); } joinRaceRoom(racetimeId: string, authToken: RoomTokenPayload) { @@ -914,10 +986,19 @@ export default class Room { return false; } this.logInfo(`Connecting ${player.nickname} to racetime`); - player.raceId = racetimeId; return player.joinRace(); } + leaveRaceRoom(authToken: RoomTokenPayload) { + const player = this.players.get(authToken.playerId); + if (!player) { + this.logWarn('Unable to find a player for a verified room token'); + return false; + } + this.logInfo(`Leaving ${player.nickname} from racetime`); + return player.leaveRace(); + } + async refreshRacetimeHandler() { this.raceHandler.refresh(); } @@ -946,6 +1027,10 @@ export default class Room { //#endregion //#region Logging + logDebug(message: string, metadata?: { [k: string]: string }) { + logDebug(message, { room: this.slug, ...metadata }); + } + logInfo(message: string, metadata?: { [k: string]: string }) { logInfo(message, { room: this.slug, ...metadata }); } @@ -993,6 +1078,32 @@ export default class Room { }); }); allRooms.delete(this.slug); + this.computeVictoryMasks(); + } + + revealCardForPlayer(player: Player) { + this.sendChat([ + { + contents: player.nickname, + color: player.color, + }, + ' has revealed the card.', + ]); + player.sendMessage({ + action: 'syncBoard', + board: { + hidden: false, + board: this.board, + width: this.board[0].length, + height: this.board.length, + }, + }); + } + + revealCardForAllPlayers() { + this.players.forEach((player) => { + this.revealCardForPlayer(player); + }); } computeVictoryMasks() { diff --git a/api/src/core/RoomServer.ts b/api/src/core/RoomServer.ts index 6622d5b6..c98b57d7 100644 --- a/api/src/core/RoomServer.ts +++ b/api/src/core/RoomServer.ts @@ -151,6 +151,15 @@ roomWebSocketServer.on('connection', (ws, req) => { } } break; + case 'startTimer': + room.handleStartTimer(); + break; + case 'changeRaceHandler': + room.handleChangeRaceHandler(action); + break; + case 'resetTimer': + room.handleResetTimer(); + break; } }); ws.on('close', (code, reason) => { diff --git a/api/src/core/integration/races/LocalTimer.ts b/api/src/core/integration/races/LocalTimer.ts new file mode 100644 index 00000000..e5061d20 --- /dev/null +++ b/api/src/core/integration/races/LocalTimer.ts @@ -0,0 +1,102 @@ +import { RaceStatusConnected } from '@playbingo/types'; +import { RaceHandler as RaceHandlers } from '@prisma/client'; +import { + createUpdatePlayer, + updateFinishTime, + updateStartTime, +} from '../../../database/Rooms'; +import Player from '../../Player'; +import Room from '../../Room'; +import RaceHandler from './RaceHandler'; + +export default class LocalTimer implements RaceHandler { + startedAt?: string; + finishedAt?: string; + room: Room; + + constructor(room: Room) { + this.room = room; + } + + key() { + return RaceHandlers.LOCAL; + } + + connect(url: string): void {} + + disconnect(): void {} + + async joinPlayer(player: Player): Promise { + return true; + } + + async leavePlayer(player: Player): Promise { + return true; + } + + async readyPlayer(player: Player): Promise { + return true; + } + + async unreadyPlayer(player: Player): Promise { + throw new Error('Method not implemented.'); + } + + async refresh(): Promise {} + + getPlayer( + player: Player, + ): Omit | undefined { + return { + username: player.id, + finishTime: player.finishedAt, + }; + } + + getStartTime(): string | undefined { + return this.startedAt; + } + + getEndTime(): string | undefined { + return this.finishedAt; + } + + resetTimer(): void { + this.startedAt = undefined; + this.finishedAt = undefined; + updateStartTime(this.room.id, null).then(); + updateFinishTime(this.room.id, null).then(); + this.room.players.forEach((player) => { + player.finishedAt = undefined; + createUpdatePlayer(this.room.id, player).then(); + }); + } + + startTimer(): void { + const now = new Date(); + this.startedAt = now.toISOString(); + this.room.revealCardForAllPlayers(); + updateStartTime(this.room.id, now).then(); + } + + async playerFinished(player: Player): Promise { + player.finishedAt = new Date().toISOString(); + createUpdatePlayer(this.room.id, player).then(); + } + + async playerUnfinshed(player: Player): Promise { + player.finishedAt = undefined; + createUpdatePlayer(this.room.id, player).then(); + } + + async allPlayersFinished(): Promise { + const now = new Date(); + this.finishedAt = now.toISOString(); + updateFinishTime(this.room.id, now).then(); + } + + async allPlayersNotFinished(): Promise { + this.finishedAt = undefined; + updateFinishTime(this.room.id, null).then(); + } +} diff --git a/api/src/core/integration/races/RaceHandler.ts b/api/src/core/integration/races/RaceHandler.ts index 21ae18a2..66b3003b 100644 --- a/api/src/core/integration/races/RaceHandler.ts +++ b/api/src/core/integration/races/RaceHandler.ts @@ -1,20 +1,33 @@ import { RaceStatusConnected } from '@playbingo/types'; +import Player from '../../Player'; +import { RaceHandler as RaceHandlers } from '@prisma/client'; /** * Represents an arbitrary connection to a service that tracks racing status. * Provides a uniform interface for the bingo room to interact with that * service, regardless of the specifics of how that service may be implemented. * - * The only requirement that this interface asserts is that the interface - * exposed by the service is either a local service on PlayBingo or operates - * via some identifier that uniquely identifies what player an action is - * targeted at. All player level actions take that identifier as a parameter. - * Generally, it is expected that this will be an OAuth token, but that is - * not a requirement to satisfy this interface. + * This interface asserts no requirement of the data storage, format, or other + * implementation details of the underling service that an instance of this + * interface represents. However, it does require that if the implementation + * represents and interface with an external service that the implementation be + * able to translate from the internal PlayBingo representation of data in order + * to interface with the service and vice versa. The most prominent example of + * of this is in the translation of player identities to their race + * counterparts, which will be different in each implementation. + * + * Authentication is considered to be an implementation detail of the service + * and is not specified as part of this interface, however, implementations of + * this interface will usually want to implement authentication as appropriate + * within their implementation. * * This interface is an implementation of the adapter design pattern. */ export default interface RaceHandler { + /** + * Returns a unique key for the race handler + */ + key(): RaceHandlers; /** * Connects the bingo room to the race room * @@ -30,22 +43,22 @@ export default interface RaceHandler { /** * Join a player into the race room */ - joinPlayer(token: string): Promise; + joinPlayer(player: Player): Promise; /** * Leave a player from the race room */ - leavePlayer(token: string): Promise; + leavePlayer(player: Player): Promise; /** * Marks a player as ready in the race room */ - readyPlayer(token: string): Promise; + readyPlayer(player: Player): Promise; /** * Marks a player as not ready in the race room */ - unreadyPlayer(token: string): Promise; + unreadyPlayer(player: Player): Promise; /** * Refreshes the local cache of data @@ -58,5 +71,51 @@ export default interface RaceHandler { * * @param id The service id of the player */ - getPlayer(id: string): Omit | undefined; + getPlayer( + player: Player, + ): Omit | undefined; + + /** + * Returns the start time of the race + */ + getStartTime(): string | undefined; + + /** + * Returns the end time of the race + */ + getEndTime(): string | undefined; + + /** + * Starts the race timer + */ + startTimer(): void; + + /** + * Resets the race timer + */ + resetTimer(): void; + + /** + * Signals to the race platform that a player has completed the bingo goal + * + * @param player The player that completed the goal + */ + playerFinished(player: Player): Promise; + + /** + * Signals to the race platform that a player has no longer completed the bingo goal + * + * @param player The player who's completion needs to be undone + */ + playerUnfinshed(player: Player): Promise; + + /** + * Signals to the race platform that all players have completed the bingo goal + */ + allPlayersFinished(): Promise; + + /** + * Signals to the race platform that all players have no longer completed the bingo goal + */ + allPlayersNotFinished(): Promise; } diff --git a/api/src/core/integration/races/RacetimeHandler.ts b/api/src/core/integration/races/RacetimeHandler.ts index da84c544..ed8f3524 100644 --- a/api/src/core/integration/races/RacetimeHandler.ts +++ b/api/src/core/integration/races/RacetimeHandler.ts @@ -4,6 +4,9 @@ import { disconnectRoomFromRacetime } from '../../../database/Rooms'; import Room from '../../Room'; import { logInfo } from '../../../Logger'; import RaceHandler from './RaceHandler'; +import Player from '../../Player'; +import { getAccessToken } from '../../../lib/RacetimeConnector'; +import { RaceHandler as RaceHandlers } from '@prisma/client'; interface User { id: string; @@ -103,6 +106,7 @@ export default class RacetimeHandler implements RaceHandler { websocketConnected = false; /** Current version of the race rooms data*/ data?: RaceData; + playersToRacers: Map = new Map(); //#region Synchronous Websocket nextAuthenticatedCallback?: SynchronousSocketCallback< @@ -116,6 +120,10 @@ export default class RacetimeHandler implements RaceHandler { this.room = room; } + key() { + return RaceHandlers.RACETIME; + } + //#region Synchronous Websocket Functions async ping() { return new Promise((resolve, reject) => { @@ -140,31 +148,46 @@ export default class RacetimeHandler implements RaceHandler { }); } - async authenticate(token: string) { - return new Promise((resolve, reject) => { - if (!this.connected || !this.websocketConnected || !this.socket) { - reject(new Error('Invalid websocket state')); - return; - } - if (this.nextAuthenticatedCallback) { - reject( - new Error('Multiple entities awaiting the same response'), + async authenticate(player: Player) { + return new Promise(async (resolve, reject) => { + if (player.userId) { + const token = await getAccessToken(player.userId); + if (!token) { + reject(new Error('Failed to generate token')); + } + + if ( + !this.connected || + !this.websocketConnected || + !this.socket + ) { + reject(new Error('Invalid websocket state')); + return; + } + if (this.nextAuthenticatedCallback) { + reject( + new Error( + 'Multiple entities awaiting the same response', + ), + ); + } else { + this.nextAuthenticatedCallback = (value) => { + if ('errors' in value) { + reject(new Error(value.errors.join())); + } else { + resolve(value); + } + }; + } + this.socket.send( + JSON.stringify({ + action: 'authenticate', + data: { oauth_token: `${token}` }, + }), ); } else { - this.nextAuthenticatedCallback = (value) => { - if ('errors' in value) { - reject(new Error(value.errors.join())); - } else { - resolve(value); - } - }; + reject(new Error('Player is anonymous')); } - this.socket.send( - JSON.stringify({ - action: 'authenticate', - data: { oauth_token: `${token}` }, - }), - ); }); } //#endregion @@ -210,6 +233,17 @@ export default class RacetimeHandler implements RaceHandler { private updateData(data: RaceData) { if (!this.data || this.data.version < data.version) { + if (this.data) { + // updating data, compare current and next and dispatch events as needed + if ( + this.data.status.value !== 'in_progress' && + data.status.value === 'in_progress' + ) { + // race has started + this.room.revealCardForAllPlayers(); + } + } + this.data = data; this.room.sendRaceData(data); } @@ -238,6 +272,7 @@ export default class RacetimeHandler implements RaceHandler { } break; case 'error': + this.room.logError(message.errors.join()); if (this.nextAuthenticatedCallback) { this.nextAuthenticatedCallback(message); this.nextAuthenticatedCallback = undefined; @@ -249,8 +284,12 @@ export default class RacetimeHandler implements RaceHandler { } } - getPlayer(id: string) { - const user = this.data?.entrants.find((u) => u.user.id === id); + getPlayer(player: Player) { + const userId = this.playersToRacers.get(player.id); + if (!userId) { + return undefined; + } + const user = this.data?.entrants.find((u) => u.user.id === userId); if (user) { return { username: user.user.full_name, @@ -261,14 +300,15 @@ export default class RacetimeHandler implements RaceHandler { return undefined; } - async joinPlayer(token: string) { + async joinPlayer(player: Player) { if (!this.connected || !this.websocketConnected || !this.socket) { logInfo('Unable to join user - room is not connected to racetime'); return false; } try { - const { user } = await this.authenticate(token); - if (this.getPlayer(user.id)) { + const { user } = await this.authenticate(player); + this.playersToRacers.set(player.id, user.id); + if (this.getPlayer(player)) { this.room.sendRaceData(this.data as RaceData); return true; } @@ -282,8 +322,27 @@ export default class RacetimeHandler implements RaceHandler { } } - async leavePlayer(token: string): Promise { - throw new Error('Not implemented'); + async leavePlayer(player: Player): Promise { + if (!this.connected || !this.websocketConnected || !this.socket) { + logInfo( + 'Unable to leave player - room is not connected to racetime', + ); + return false; + } + if (!this.playersToRacers.has(player.id)) { + return false; + } + try { + await this.authenticate(player); + this.playersToRacers.delete(player.id); + this.socket.send(JSON.stringify({ action: 'leave' })); + return true; + } catch (e) { + this.room.logInfo( + `Failed to join racetime room - ${JSON.stringify(e)}`, + ); + return false; + } } async refresh() { @@ -329,7 +388,7 @@ export default class RacetimeHandler implements RaceHandler { this.room.sendRaceData(this.data); } - async readyPlayer(token: string) { + async readyPlayer(player: Player) { if (!this.connected || !this.websocketConnected || !this.socket) { this.room.logInfo( 'Unable to ready - room is not connected to racetime', @@ -337,7 +396,7 @@ export default class RacetimeHandler implements RaceHandler { return false; } try { - await this.authenticate(token); + await this.authenticate(player); this.socket.send(JSON.stringify({ action: 'ready' })); return true; } catch (e) { @@ -348,7 +407,7 @@ export default class RacetimeHandler implements RaceHandler { } } - async unreadyPlayer(token: string) { + async unreadyPlayer(player: Player) { if (!this.connected || !this.websocketConnected || !this.socket) { this.room.logInfo( 'Unable to unready - room is not connected to racetime', @@ -356,7 +415,7 @@ export default class RacetimeHandler implements RaceHandler { return false; } try { - await this.authenticate(token); + await this.authenticate(player); this.socket.send(JSON.stringify({ action: 'unready' })); return true; } catch (e) { @@ -366,4 +425,55 @@ export default class RacetimeHandler implements RaceHandler { return false; } } + + getStartTime(): string | undefined { + return this.data?.started_at ?? undefined; + } + + getEndTime(): string | undefined { + return this.data?.ended_at ?? undefined; + } + + // racetime player websocket doesn't support these actions + resetTimer(): void {} + startTimer(): void {} + + async playerFinished(player: Player): Promise { + if (!this.connected || !this.websocketConnected || !this.socket) { + logInfo( + 'Unable to finish player in racetime - room is not connected to racetime', + ); + return; + } + try { + await this.authenticate(player); + this.socket.send(JSON.stringify({ action: 'done' })); + } catch (e) { + this.room.logInfo( + `Failed to join racetime room - ${JSON.stringify(e)}`, + ); + } + } + + async playerUnfinshed(player: Player): Promise { + if (!this.connected || !this.websocketConnected || !this.socket) { + logInfo( + 'Unable to unfinish player in racetime - room is not connected to racetime', + ); + return; + } + try { + await this.authenticate(player); + this.socket.send(JSON.stringify({ action: 'undone' })); + } catch (e) { + this.room.logInfo( + `Failed to join racetime room - ${JSON.stringify(e)}`, + ); + } + } + + // no implementation - this is handled by racetime and synced back + // automatically via the websocket + async allPlayersFinished(): Promise {} + async allPlayersNotFinished(): Promise {} } diff --git a/api/src/database/Rooms.ts b/api/src/database/Rooms.ts index 04ccdb8d..1e903368 100644 --- a/api/src/database/Rooms.ts +++ b/api/src/database/Rooms.ts @@ -1,4 +1,4 @@ -import { BingoMode, RoomActionType } from '@prisma/client'; +import { BingoMode, RaceHandler, RoomActionType } from '@prisma/client'; import { prisma } from './Database'; import { JsonObject } from '@prisma/client/runtime/library'; import Player from '../core/Player'; @@ -128,6 +128,7 @@ export const createUpdatePlayer = async (room: string, player: Player) => { : undefined, spectator: player.spectator, monitor: player.monitor, + finishedAt: player.finishedAt, }, update: { nickname: player.nickname, @@ -137,6 +138,33 @@ export const createUpdatePlayer = async (room: string, player: Player) => { : { disconnect: true }, spectator: player.spectator, monitor: player.monitor, + finishedAt: player.finishedAt ?? null, }, }); }; + +export const updateStartTime = async (room: string, startedAt: Date | null) => { + return prisma.room.update({ + where: { id: room }, + data: { startedAt }, + }); +}; +export const updateFinishTime = async ( + room: string, + finishedAt: Date | null, +) => { + return prisma.room.update({ + where: { id: room }, + data: { finishedAt }, + }); +}; + +export const updateRaceHandler = async ( + room: string, + raceHandler: RaceHandler, +) => { + return prisma.room.update({ + where: { id: room }, + data: { raceHandler }, + }); +}; diff --git a/api/src/routes/rooms/Rooms.ts b/api/src/routes/rooms/Rooms.ts index 4c91680e..b9eab969 100644 --- a/api/src/routes/rooms/Rooms.ts +++ b/api/src/routes/rooms/Rooms.ts @@ -26,8 +26,11 @@ import Player from '../../core/Player'; import { GeneratorSettings, makeGeneratorSchema } from '@playbingo/shared'; import { getCategories } from '../../database/games/GoalCategories'; import { getVariant } from '../../database/games/Variants'; -import { DifficultyVariant, Variant } from '@prisma/client'; +import { DifficultyVariant, RaceHandler, Variant } from '@prisma/client'; import { GenerationFailedError } from '../../core/generation/GenerationFailedError'; +import RacetimeHandler from '../../core/integration/races/RacetimeHandler'; +import LocalTimer from '../../core/integration/races/LocalTimer'; +import { error } from 'console'; const MIN_ROOM_GOALS_REQUIRED = 25; const rooms = Router(); @@ -335,6 +338,7 @@ async function getOrLoadRoom(slug: string): Promise { dbPlayer.monitor, dbPlayer.userId ?? undefined, ); + player.finishedAt = dbPlayer.finishedAt?.toISOString(); newRoom.players.set(player.id, player); }); @@ -401,6 +405,21 @@ async function getOrLoadRoom(slug: string): Promise { break; } }); + switch (dbRoom.raceHandler) { + case RaceHandler.RACETIME: + newRoom.raceHandler = new RacetimeHandler(newRoom); + if (dbRoom.racetimeRoom) { + newRoom.raceHandler.connect(dbRoom.racetimeRoom); + await newRoom.connectRacetimeWebSocket(); + } + break; + case RaceHandler.LOCAL: + const handler = new LocalTimer(newRoom); + handler.startedAt = dbRoom.startedAt?.toISOString(); + handler.finishedAt = dbRoom.finishedAt?.toISOString(); + newRoom.raceHandler = handler; + break; + } allRooms.set(slug, newRoom); return newRoom; @@ -423,11 +442,10 @@ rooms.get('/:slug', async (req, res) => { newGenerator: room.newGenerator, racetimeConnection: { gameActive: room.racetimeEligible, - url: room.raceHandler.url, - startDelay: room.raceHandler.data?.start_delay, - started: room.raceHandler.data?.started_at ?? undefined, - ended: room.raceHandler.data?.ended_at ?? undefined, - status: room.raceHandler.data?.status.verbose_value, + url: (room.raceHandler as RacetimeHandler).url, + startDelay: (room.raceHandler as RacetimeHandler).data?.start_delay, + status: (room.raceHandler as RacetimeHandler).data?.status + .verbose_value, }, mode: room.bingoMode, variant: room.variantName, diff --git a/api/src/routes/rooms/actions/RacetimeActions.ts b/api/src/routes/rooms/actions/RacetimeActions.ts index d6cd9c04..6aa4effb 100644 --- a/api/src/routes/rooms/actions/RacetimeActions.ts +++ b/api/src/routes/rooms/actions/RacetimeActions.ts @@ -47,6 +47,14 @@ export const handleRacetimeAction = async ( }; } return joinPlayer(room, roomToken, rtConnection.serviceId); + case 'leave': + if (roomToken.isSpectating) { + return { + code: 403, + message: 'Forbidden', + }; + } + return leavePlayer(room, roomToken); case 'ready': if (roomToken.isSpectating) { return { @@ -167,6 +175,22 @@ const joinPlayer = async ( }; }; +const leavePlayer = async ( + room: Room, + roomToken: RoomTokenPayload, +): Promise => { + if (!room.leaveRaceRoom(roomToken)) { + return { + code: 403, + message: 'Forbidden', + }; + } + return { + code: 200, + value: {}, + }; +}; + const readyPlayer = async ( room: Room, roomToken: RoomTokenPayload, diff --git a/schema/schemas/RoomAction.json b/schema/schemas/RoomAction.json index a103f286..7cb8d2ac 100644 --- a/schema/schemas/RoomAction.json +++ b/schema/schemas/RoomAction.json @@ -16,7 +16,11 @@ {"$ref": "#/$defs/ChangeColorAction"}, {"$ref": "#/$defs/NewCardAction"}, {"$ref": "#/$defs/RevealCardAction"}, - {"$ref": "#/$defs/ChangeAuthAction"} + {"$ref": "#/$defs/ChangeAuthAction"}, + {"$ref": "#/$defs/StartTimerAction"}, + {"$ref": "#/$defs/ChangeRaceHandlerAction"}, + {"$ref": "#/$defs/ResetTimerAction"} + ], "$defs": { "JoinAction": { @@ -134,6 +138,28 @@ } } } + }, + "StartTimerAction": { + "required": ["action"], + "additionalProperties": false, + "properties": { + "action": "startTimer" + } + }, + "ChangeRaceHandlerAction": { + "required": ["action", "raceHandler"], + "additionalProperties": false, + "properties": { + "action": "changeRaceHandler", + "raceHandler": {"enum": ["racetime", "local"]} + } + }, + "ResetTimerAction": { + "required": ["action", "raceHandler"], + "additionalProperties": false, + "properties": { + "action": "resetTimer" + } } } } \ No newline at end of file diff --git a/schema/schemas/RoomData.json b/schema/schemas/RoomData.json index 19d7bd97..48f0bf7e 100644 --- a/schema/schemas/RoomData.json +++ b/schema/schemas/RoomData.json @@ -40,6 +40,15 @@ }, "mode": { "type": "string" + }, + "startedAt": { + "type": "string" + }, + "finishedAt": { + "type": "string" + }, + "raceHandler": { + "enum": ["LOCAL", "RACETIME"] } }, "$defs": { @@ -66,14 +75,6 @@ "startDelay": { "type": "string", "description": "ISO 8601 duration string representing the amount of time between ready and start" - }, - "started": { - "type": "string", - "description": "ISO 8601 date when the race started" - }, - "ended": { - "type": "string", - "description": "ISO 8601 date when the race ended" } } } diff --git a/schema/schemas/ServerMessage.json b/schema/schemas/ServerMessage.json index b2b684bb..6f06bab4 100644 --- a/schema/schemas/ServerMessage.json +++ b/schema/schemas/ServerMessage.json @@ -161,6 +161,19 @@ "type": "string" } } + }, + { + "required": [ + "action", + "startTime" + ], + "additionalProperties": false, + "properties": { + "action": "startTimer", + "startTime": { + "type": "string" + } + } } ] } \ No newline at end of file diff --git a/schema/types/RoomAction.d.ts b/schema/types/RoomAction.d.ts index 800b211f..da9a7a1c 100644 --- a/schema/types/RoomAction.d.ts +++ b/schema/types/RoomAction.d.ts @@ -18,6 +18,9 @@ export type RoomAction = ( | NewCardAction | RevealCardAction | ChangeAuthAction + | StartTimerAction + | ChangeRaceHandlerAction + | ResetTimerAction ) & { /** * JWT for the room obtained from the server @@ -77,3 +80,13 @@ export interface ChangeAuthAction { spectate: boolean; }; } +export interface StartTimerAction { + action: "startTimer"; +} +export interface ChangeRaceHandlerAction { + action: "changeRaceHandler"; + raceHandler: "racetime" | "local"; +} +export interface ResetTimerAction { + action: "resetTimer"; +} diff --git a/schema/types/RoomData.d.ts b/schema/types/RoomData.d.ts index 4702523a..65503643 100644 --- a/schema/types/RoomData.d.ts +++ b/schema/types/RoomData.d.ts @@ -21,6 +21,9 @@ export interface RoomData { token?: string; variant: string; mode: string; + startedAt?: string; + finishedAt?: string; + raceHandler?: "LOCAL" | "RACETIME"; } export interface RacetimeConnection { /** @@ -43,12 +46,4 @@ export interface RacetimeConnection { * ISO 8601 duration string representing the amount of time between ready and start */ startDelay?: string; - /** - * ISO 8601 date when the race started - */ - started?: string; - /** - * ISO 8601 date when the race ended - */ - ended?: string; } diff --git a/schema/types/ServerMessage.d.ts b/schema/types/ServerMessage.d.ts index 27a519dc..b5dfabf0 100644 --- a/schema/types/ServerMessage.d.ts +++ b/schema/types/ServerMessage.d.ts @@ -51,6 +51,10 @@ export type ServerMessage = ( action: "reauthenticate"; authToken: string; } + | { + action: "startTimer"; + startTime: string; + } ) & { players?: Player[]; connectedPlayer?: Player; @@ -120,6 +124,9 @@ export interface RoomData { token?: string; variant: string; mode: string; + startedAt?: string; + finishedAt?: string; + raceHandler?: "LOCAL" | "RACETIME"; } export interface RacetimeConnection { /** @@ -142,14 +149,6 @@ export interface RacetimeConnection { * ISO 8601 duration string representing the amount of time between ready and start */ startDelay?: string; - /** - * ISO 8601 date when the race started - */ - started?: string; - /** - * ISO 8601 date when the race ended - */ - ended?: string; } export interface Player { id: string; diff --git a/web/public/rtgg128.png b/web/public/rtgg128.png new file mode 100644 index 00000000..3d45ae2d Binary files /dev/null and b/web/public/rtgg128.png differ diff --git a/web/src/app/(main)/rooms/[slug]/page.tsx b/web/src/app/(main)/rooms/[slug]/page.tsx index 6abc9471..9b6b2333 100644 --- a/web/src/app/(main)/rooms/[slug]/page.tsx +++ b/web/src/app/(main)/rooms/[slug]/page.tsx @@ -2,10 +2,10 @@ import Board from '@/components/board/Board'; import PlayerInfo from '@/components/room/PlayerInfo'; import PlayerList from '@/components/room/PlayerList'; -import RacetimeCard from '@/components/room/racetime/RacetimeCard'; import RoomChat from '@/components/room/RoomChat'; import RoomInfo from '@/components/room/RoomInfo'; import RoomLogin from '@/components/room/RoomLogin'; +import Timer from '@/components/room/timer/Timer'; import { ConnectionStatus, useRoomContext } from '@/context/RoomContext'; import { Box, Dialog, DialogContent, Stack } from '@mui/material'; @@ -116,7 +116,7 @@ function RoomXs() { return ( - + - + @@ -194,7 +194,7 @@ function RoomMd() { - + @@ -258,7 +258,7 @@ function RoomLg() { - + - + diff --git a/web/src/components/room/PlayerList.tsx b/web/src/components/room/PlayerList.tsx index e6226689..7a30c5b6 100644 --- a/web/src/components/room/PlayerList.tsx +++ b/web/src/components/room/PlayerList.tsx @@ -1,14 +1,14 @@ import { Box, Paper, Typography } from '@mui/material'; -import { Duration } from 'luxon'; import { Sword } from 'mdi-material-ui'; import { useContext } from 'react'; import { RoomContext } from '../../context/RoomContext'; +import PlayerRaceSummary from './PlayerRaceSummary'; export default function PlayerList() { const { players: allPlayers, roomData } = useContext(RoomContext); - const racetimeConnected = !!roomData?.racetimeConnection?.url; const players = allPlayers.filter((p) => !p.spectator); const spectators = allPlayers.filter((p) => p.spectator); + return ( {player.nickname} - {racetimeConnected && ( - <> - {!player.raceStatus.connected && ( - Not connected - )} - {player.raceStatus.connected && ( - - {player.raceStatus.username} -{' '} - {player.raceStatus.ready - ? 'Ready' - : 'Not ready'} - {player.raceStatus.finishTime && - ` - ${Duration.fromISO(player.raceStatus.finishTime).toFormat('h:mm:ss')}`} - - )} - + {roomData?.raceHandler && ( + )} ))} diff --git a/web/src/components/room/PlayerRaceSummary.tsx b/web/src/components/room/PlayerRaceSummary.tsx new file mode 100644 index 00000000..391605ae --- /dev/null +++ b/web/src/components/room/PlayerRaceSummary.tsx @@ -0,0 +1,63 @@ +import { Box, Typography } from '@mui/material'; +import { Player, RoomData } from '@playbingo/types'; +import { DateTime, Duration } from 'luxon'; +import { useRoomContext } from '../../context/RoomContext'; + +interface Props { + raceHandler: RoomData['raceHandler']; + player: Player; +} + +export default function PlayerRaceSummary({ raceHandler, player }: Props) { + if (!player.raceStatus) { + return null; + } + + switch (raceHandler) { + case 'RACETIME': + return ; + case 'LOCAL': + return ; + } +} + +function PlayerSummaryRacetime({ player }: Omit) { + return ( + + {player.raceStatus?.connected ? ( + + {player.raceStatus.username} -{' '} + {player.raceStatus.ready ? 'Ready' : 'Not ready'} + {player.raceStatus.finishTime && + ` - ${Duration.fromISO(player.raceStatus.finishTime).toFormat('h:mm:ss')}`} + + ) : ( + Not connected + )} + + ); +} +function PlayerSummaryLocal({ player }: Omit) { + const { roomData } = useRoomContext(); + + if (!roomData || !roomData.startedAt || !player.raceStatus.connected) { + return null; + } + + const startDt = DateTime.fromISO(roomData.startedAt); + + if (!player.raceStatus.finishTime) { + return Not finished; + } + const finishDt = DateTime.fromISO(player.raceStatus.finishTime); + + return ( + + {player.raceStatus?.connected && player.raceStatus.finishTime ? ( + + {finishDt.diff(startDt).toFormat('h:mm:ss')} + + ) : null} + + ); +} diff --git a/web/src/components/room/RoomControlDialog.tsx b/web/src/components/room/RoomControlDialog.tsx index 453b512d..d8e8dd86 100644 --- a/web/src/components/room/RoomControlDialog.tsx +++ b/web/src/components/room/RoomControlDialog.tsx @@ -1,3 +1,4 @@ +import { Timer } from '@mui/icons-material'; import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import { Accordion, @@ -12,14 +13,18 @@ import { InputLabel, MenuItem, Select, + ToggleButton, + ToggleButtonGroup, Typography, } from '@mui/material'; import { Game } from '@playbingo/types'; import { Form, Formik } from 'formik'; +import Image from 'next/image'; import { useContext } from 'react'; import { useAsync } from 'react-use'; import { RoomContext } from '../../context/RoomContext'; import FormikTextField from '../input/FormikTextField'; +import rtLogo from '/public/rtgg128.png'; interface RoomControlDialogProps { show: boolean; @@ -30,7 +35,15 @@ export default function RoomControlDialog({ show, close, }: RoomControlDialogProps) { - const { roomData, regenerateCard } = useContext(RoomContext); + const { roomData, regenerateCard, changeRaceHandler, connectedPlayer } = + useContext(RoomContext); + + const handleRaceHandlerChange = ( + event: React.MouseEvent, + handler: string | null, + ) => { + changeRaceHandler(handler ?? ''); + }; const modes = useAsync(async () => { if (!roomData) { @@ -58,8 +71,48 @@ export default function RoomControlDialog({ Room Controls + {connectedPlayer?.monitor && ( + + Monitor Actions + Timing Method + + + + + Basic Timer + + + + + + racetime.gg + + + + + )} Card Controls - @@ -103,6 +106,11 @@ export default function RacetimeCard() { Ready )} + )} @@ -113,7 +121,7 @@ export default function RacetimeCard() { {status && ( <> {status} - + )} diff --git a/web/src/components/room/timer/Timer.tsx b/web/src/components/room/timer/Timer.tsx new file mode 100644 index 00000000..ac401239 --- /dev/null +++ b/web/src/components/room/timer/Timer.tsx @@ -0,0 +1,56 @@ +import { RoomContext } from '@/context/RoomContext'; +import { Check, FastRewind, PlayArrow } from '@mui/icons-material'; +import { Button, ButtonGroup, Card, CardContent } from '@mui/material'; +import { Duration } from 'luxon'; +import { useContext } from 'react'; +import RacetimeCard from '../racetime/RacetimeCard'; +import TimerDisplay from './TimerDisplay'; + +export default function Timer() { + const { roomData, startTimer, resetTimer, connectedPlayer } = + useContext(RoomContext); + + if (!roomData) { + return null; + } + + const { racetimeConnection } = roomData; + + if (racetimeConnection) { + return ; + } + + const offset = Duration.fromDurationLike(0); + + const started = !!roomData.startedAt; + + return ( + + + + + + {started ? ( + + ) : ( + + )} + + + + ); +} diff --git a/web/src/components/room/racetime/Timer.tsx b/web/src/components/room/timer/TimerDisplay.tsx similarity index 54% rename from web/src/components/room/racetime/Timer.tsx rename to web/src/components/room/timer/TimerDisplay.tsx index 1b791edd..9b3ed946 100644 --- a/web/src/components/room/racetime/Timer.tsx +++ b/web/src/components/room/timer/TimerDisplay.tsx @@ -1,46 +1,23 @@ import { Typography } from '@mui/material'; import { DateTime, Duration } from 'luxon'; -import { useContext, useEffect, useState } from 'react'; +import { useEffect, useState } from 'react'; import { useInterval } from 'react-use'; -import { RoomContext } from '../../../context/RoomContext'; +import { useRoomContext } from '../../../context/RoomContext'; -export default function Timer() { - const { roomData } = useContext(RoomContext); +export default function TimerDisplay({ offset }: { offset: Duration }) { + const { roomData } = useRoomContext(); - if (!roomData || !roomData.racetimeConnection) { - return null; - } - - const { - racetimeConnection: { startDelay, started, ended }, - } = roomData; - - if (!startDelay) { - return null; - } + const { startedAt, finishedAt } = roomData!; - let startDt: DateTime | undefined; - if (started) { - startDt = DateTime.fromISO(started); + let start: DateTime | undefined; + if (startedAt) { + start = DateTime.fromISO(startedAt); } - let endDt: DateTime | undefined; - if (ended) { - endDt = DateTime.fromISO(ended); + let end: DateTime | undefined; + if (finishedAt) { + end = DateTime.fromISO(finishedAt); } - const offset = Duration.fromISO(startDelay); - return ; -} - -function TimerDisplay({ - start, - offset, - end, -}: { - start?: DateTime; - end?: DateTime; - offset: Duration; -}) { const [updateTimer, setUpdateTimer] = useState(true); const [dur, setDur] = useState( start && end ? end.diff(start) : offset, @@ -64,6 +41,8 @@ function TimerDisplay({ setDur(end.diff(start)); } else if (start) { setDur(DateTime.now().diff(start).normalize()); + } else { + setDur(offset); } }, interval); @@ -80,8 +59,9 @@ function TimerDisplay({ }, []); return ( - - {dur.toFormat('h:mm:ss')}.{dur.milliseconds % 10} + + {dur.shiftToAll().toFormat('h:mm:ss')}. + {Math.floor((dur.milliseconds % 1000) / 100)} ); } diff --git a/web/src/context/RoomContext.tsx b/web/src/context/RoomContext.tsx index 3192d62b..a2e39219 100644 --- a/web/src/context/RoomContext.tsx +++ b/web/src/context/RoomContext.tsx @@ -74,6 +74,7 @@ interface RoomContext { createRacetimeRoom: () => void; updateRacetimeRoom: () => void; joinRacetimeRoom: () => void; + leaveRacetimeRoom: () => void; racetimeReady: () => void; racetimeUnready: () => void; toggleGoalStar: (row: number, col: number) => void; @@ -81,6 +82,9 @@ interface RoomContext { toggleGoalDetails: () => void; toggleCounters: () => void; changeAuth: (spectate: boolean) => void; + startTimer: () => void; + changeRaceHandler: (handler: string) => void; + resetTimer: () => void; } export const RoomContext = createContext({ @@ -106,6 +110,7 @@ export const RoomContext = createContext({ createRacetimeRoom() {}, updateRacetimeRoom() {}, joinRacetimeRoom() {}, + leaveRacetimeRoom() {}, racetimeReady() {}, racetimeUnready() {}, toggleGoalStar() {}, @@ -113,6 +118,9 @@ export const RoomContext = createContext({ toggleGoalDetails() {}, toggleCounters() {}, changeAuth() {}, + startTimer() {}, + changeRaceHandler() {}, + resetTimer() {}, }); interface RoomContextProps { @@ -440,6 +448,23 @@ export function RoomContextProvider({ return; } }, [roomData, authToken, connectedPlayer]); + const leaveRacetimeRoom = useCallback(async () => { + if (connectedPlayer?.spectator) { + return; + } + const res = await fetch(`/api/rooms/${roomData.slug}/actions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action: 'racetime/leave', + authToken, + }), + }); + if (!res.ok) { + alertError(await res.text()); + return; + } + }, [roomData, authToken, connectedPlayer]); const racetimeReady = useCallback(async () => { if (connectedPlayer?.spectator) { return; @@ -500,6 +525,28 @@ export function RoomContextProvider({ }, [sendJsonMessage, authToken], ); + const startTimer = useCallback(() => { + sendJsonMessage({ + action: 'startTimer', + authToken, + } as RoomAction); + }, [sendJsonMessage, authToken]); + const changeRaceHandler = useCallback( + (handler: string) => { + sendJsonMessage({ + action: 'changeRaceHandler', + authToken, + raceHandler: handler, + } as RoomAction); + }, + [authToken, sendJsonMessage], + ); + const resetTimer = useCallback(() => { + sendJsonMessage({ + action: 'resetTimer', + authToken, + } as RoomAction); + }, [authToken, sendJsonMessage]); return ( {children}