From 3243a6a0f748f252a230111bae40a6b4ed42e6c6 Mon Sep 17 00:00:00 2001 From: cjs8487 Date: Wed, 10 Dec 2025 22:10:26 -0600 Subject: [PATCH 01/16] incredibly basic timer system separate from rt --- api/src/auth/RoomAuth.ts | 2 + api/src/core/Player.ts | 2 + api/src/core/Room.ts | 45 ++++++++++ api/src/core/RoomServer.ts | 4 + schema/schemas/RoomAction.json | 10 ++- schema/schemas/RoomData.json | 6 ++ schema/schemas/ServerMessage.json | 13 +++ schema/types/RoomAction.d.ts | 4 + schema/types/RoomData.d.ts | 2 + schema/types/ServerMessage.d.ts | 6 ++ web/src/app/(main)/rooms/[slug]/page.tsx | 3 + web/src/components/room/Timer.tsx | 88 +++++++++++++++++++ .../components/room/racetime/RacetimeCard.tsx | 2 +- .../racetime/{Timer.tsx => RacetimeTimer.tsx} | 2 +- web/src/context/RoomContext.tsx | 9 ++ 15 files changed, 195 insertions(+), 3 deletions(-) create mode 100644 web/src/components/room/Timer.tsx rename web/src/components/room/racetime/{Timer.tsx => RacetimeTimer.tsx} (98%) diff --git a/api/src/auth/RoomAuth.ts b/api/src/auth/RoomAuth.ts index 67b7378d..2cafc674 100644 --- a/api/src/auth/RoomAuth.ts +++ b/api/src/auth/RoomAuth.ts @@ -75,6 +75,8 @@ export const hasPermission = ( return payload.isMonitor; case 'changeColor': return !payload.isSpectating; + case 'startTimer': + return payload.isMonitor; default: return true; } diff --git a/api/src/core/Player.ts b/api/src/core/Player.ts index a870eb82..141ef9bd 100644 --- a/api/src/core/Player.ts +++ b/api/src/core/Player.ts @@ -64,6 +64,8 @@ export default class Player { raceHandler: RaceHandler; raceId: string; + finishedAt?: string; + constructor( room: Room, id: string, diff --git a/api/src/core/Room.ts b/api/src/core/Room.ts index 9e7c2ac2..9da982c7 100644 --- a/api/src/core/Room.ts +++ b/api/src/core/Room.ts @@ -108,6 +108,9 @@ export default class Room { exploration: boolean = false; alwaysRevealedMask: bigint = 0n; + startedAt?: string; + finishedAt?: string; + lastGenerationMode: BoardGenerationOptions; victoryMasks: bigint[]; @@ -436,6 +439,8 @@ export default class Room { }, mode: getModeString(this.bingoMode, this.lineCount), variant: this.variantName, + startedAt: this.startedAt, + finishedAt: this.finishedAt, }, players: this.getPlayers(), }; @@ -589,6 +594,11 @@ export default class Room { } } + handleStartTimer() { + this.startedAt = new Date().toISOString(); + this.sendRoomData(); + } + handleSocketClose(ws: WebSocket) { let player: Player | undefined; for (const p of this.players.values()) { @@ -733,6 +743,26 @@ export default class Room { }); } + sendRoomData() { + this.sendServerMessage({ + action: 'updateRoomData', + roomData: { + game: this.game, + slug: this.slug, + name: this.name, + gameSlug: this.gameSlug, + racetimeConnection: { + url: undefined, + }, + newGenerator: this.newGenerator, + mode: getModeString(this.bingoMode, this.lineCount), + variant: this.variantName, + startedAt: this.startedAt, + finishedAt: this.finishedAt, + }, + }); + } + private sendServerMessage( message: ServerMessage, updateInactivity: boolean = true, @@ -764,6 +794,7 @@ export default class Room { ' has achieved lockout!', ]); player.goalComplete = true; + player.finishedAt = new Date().toISOString(); } if (player.goalComplete && player.goalCount < goalsNeeded) { this.sendChat([ @@ -774,6 +805,7 @@ export default class Room { ' no longer has lockout.', ]); player.goalComplete = false; + player.finishedAt = undefined; } } else { if (this.bingoMode === BingoMode.LINES) { @@ -796,6 +828,7 @@ export default class Room { !player.goalComplete ) { player.goalComplete = true; + player.finishedAt = new Date().toISOString(); this.sendChat([ { contents: player.nickname, @@ -808,6 +841,7 @@ export default class Room { player.goalComplete ) { player.goalComplete = false; + player.finishedAt = undefined; this.sendChat([ { contents: player.nickname, @@ -823,6 +857,7 @@ export default class Room { ); if (complete && !player.goalComplete) { player.goalComplete = true; + player.finishedAt = new Date().toISOString(); this.sendChat([ { contents: player.nickname, @@ -832,6 +867,7 @@ export default class Room { ]); } else if (!complete && player.goalComplete) { player.goalComplete = false; + player.finishedAt = undefined; this.sendChat([ { contents: player.nickname, @@ -850,6 +886,15 @@ export default class Room { } }); this.completed = allComplete; + if (this.completed) { + this.finishedAt = new Date().toISOString(); + this.sendRoomData(); + } else { + if (this.finishedAt) { + this.finishedAt = undefined; + this.sendRoomData(); + } + } } /** diff --git a/api/src/core/RoomServer.ts b/api/src/core/RoomServer.ts index 6622d5b6..1c6332a3 100644 --- a/api/src/core/RoomServer.ts +++ b/api/src/core/RoomServer.ts @@ -151,6 +151,10 @@ roomWebSocketServer.on('connection', (ws, req) => { } } break; + case 'startTimer': { + room.handleStartTimer(); + break; + } } }); ws.on('close', (code, reason) => { diff --git a/schema/schemas/RoomAction.json b/schema/schemas/RoomAction.json index a103f286..99ad0bf4 100644 --- a/schema/schemas/RoomAction.json +++ b/schema/schemas/RoomAction.json @@ -16,7 +16,8 @@ {"$ref": "#/$defs/ChangeColorAction"}, {"$ref": "#/$defs/NewCardAction"}, {"$ref": "#/$defs/RevealCardAction"}, - {"$ref": "#/$defs/ChangeAuthAction"} + {"$ref": "#/$defs/ChangeAuthAction"}, + {"$ref": "#/$defs/StartTimerAction"} ], "$defs": { "JoinAction": { @@ -134,6 +135,13 @@ } } } + }, + "StartTimerAction": { + "required": ["action"], + "additionalProperties": false, + "properties": { + "action": "startTimer" + } } } } \ No newline at end of file diff --git a/schema/schemas/RoomData.json b/schema/schemas/RoomData.json index 19d7bd97..ebf6b90b 100644 --- a/schema/schemas/RoomData.json +++ b/schema/schemas/RoomData.json @@ -40,6 +40,12 @@ }, "mode": { "type": "string" + }, + "startedAt": { + "type": "string" + }, + "finishedAt": { + "type": "string" } }, "$defs": { 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..b20230a7 100644 --- a/schema/types/RoomAction.d.ts +++ b/schema/types/RoomAction.d.ts @@ -18,6 +18,7 @@ export type RoomAction = ( | NewCardAction | RevealCardAction | ChangeAuthAction + | StartTimerAction ) & { /** * JWT for the room obtained from the server @@ -77,3 +78,6 @@ export interface ChangeAuthAction { spectate: boolean; }; } +export interface StartTimerAction { + action: "startTimer"; +} diff --git a/schema/types/RoomData.d.ts b/schema/types/RoomData.d.ts index 4702523a..40ad3cf3 100644 --- a/schema/types/RoomData.d.ts +++ b/schema/types/RoomData.d.ts @@ -21,6 +21,8 @@ export interface RoomData { token?: string; variant: string; mode: string; + startedAt?: string; + finishedAt?: string; } export interface RacetimeConnection { /** diff --git a/schema/types/ServerMessage.d.ts b/schema/types/ServerMessage.d.ts index 27a519dc..9fc77a4b 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,8 @@ export interface RoomData { token?: string; variant: string; mode: string; + startedAt?: string; + finishedAt?: string; } export interface RacetimeConnection { /** diff --git a/web/src/app/(main)/rooms/[slug]/page.tsx b/web/src/app/(main)/rooms/[slug]/page.tsx index 6abc9471..bdae565f 100644 --- a/web/src/app/(main)/rooms/[slug]/page.tsx +++ b/web/src/app/(main)/rooms/[slug]/page.tsx @@ -6,6 +6,7 @@ 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'; import { ConnectionStatus, useRoomContext } from '@/context/RoomContext'; import { Box, Dialog, DialogContent, Stack } from '@mui/material'; @@ -259,6 +260,7 @@ function RoomLg() { + + diff --git a/web/src/components/room/Timer.tsx b/web/src/components/room/Timer.tsx new file mode 100644 index 00000000..73e107d4 --- /dev/null +++ b/web/src/components/room/Timer.tsx @@ -0,0 +1,88 @@ +import { Button, Card, CardContent, Typography } from '@mui/material'; +import { DateTime, Duration } from 'luxon'; +import { useContext, useEffect, useState } from 'react'; +import { useInterval } from 'react-use'; +import { RoomContext } from '@/context/RoomContext'; + +export default function Timer() { + const { roomData, startTimer } = useContext(RoomContext); + + if (!roomData) { + return null; + } + + const { startedAt, finishedAt } = roomData; + + let startDt: DateTime | undefined; + if (startedAt) { + startDt = DateTime.fromISO(startedAt); + } + let endDt: DateTime | undefined; + if (finishedAt) { + endDt = DateTime.fromISO(finishedAt); + } + const offset = Duration.fromDurationLike(0); + + 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, + ); + + let interval; + if (updateTimer) { + if (end) { + interval = null; + } else if (start) { + interval = 10; + } else { + interval = 1000; + } + } else { + interval = null; + } + + useInterval(() => { + if (end && start) { + setDur(end.diff(start)); + } else if (start) { + setDur(DateTime.now().diff(start).normalize()); + } + }, interval); + + useEffect(() => { + const callback = () => { + if (document.hidden) { + setUpdateTimer(false); + } else { + setUpdateTimer(true); + } + }; + document.addEventListener('visibilitychange', callback); + return () => document.removeEventListener('visibilitychange', callback); + }, []); + + return ( + + {dur.toFormat('h:mm:ss')}.{dur.milliseconds % 10} + + ); +} diff --git a/web/src/components/room/racetime/RacetimeCard.tsx b/web/src/components/room/racetime/RacetimeCard.tsx index d3e8cfe0..abb36d3f 100644 --- a/web/src/components/room/racetime/RacetimeCard.tsx +++ b/web/src/components/room/racetime/RacetimeCard.tsx @@ -11,7 +11,7 @@ import { import NextLink from 'next/link'; import { useRoomContext } from '../../../context/RoomContext'; import { useUserContext } from '../../../context/UserContext'; -import Timer from './Timer'; +import Timer from './RacetimeTimer'; export default function RacetimeCard() { const { diff --git a/web/src/components/room/racetime/Timer.tsx b/web/src/components/room/racetime/RacetimeTimer.tsx similarity index 98% rename from web/src/components/room/racetime/Timer.tsx rename to web/src/components/room/racetime/RacetimeTimer.tsx index 1b791edd..861f1f9a 100644 --- a/web/src/components/room/racetime/Timer.tsx +++ b/web/src/components/room/racetime/RacetimeTimer.tsx @@ -4,7 +4,7 @@ import { useContext, useEffect, useState } from 'react'; import { useInterval } from 'react-use'; import { RoomContext } from '../../../context/RoomContext'; -export default function Timer() { +export default function RacetimeTimer() { const { roomData } = useContext(RoomContext); if (!roomData || !roomData.racetimeConnection) { diff --git a/web/src/context/RoomContext.tsx b/web/src/context/RoomContext.tsx index 3192d62b..e6d55ce8 100644 --- a/web/src/context/RoomContext.tsx +++ b/web/src/context/RoomContext.tsx @@ -81,6 +81,7 @@ interface RoomContext { toggleGoalDetails: () => void; toggleCounters: () => void; changeAuth: (spectate: boolean) => void; + startTimer: () => void; } export const RoomContext = createContext({ @@ -113,6 +114,7 @@ export const RoomContext = createContext({ toggleGoalDetails() {}, toggleCounters() {}, changeAuth() {}, + startTimer() {}, }); interface RoomContextProps { @@ -500,6 +502,12 @@ export function RoomContextProvider({ }, [sendJsonMessage, authToken], ); + const startTimer = useCallback(() => { + sendJsonMessage({ + action: 'startTimer', + authToken, + } as RoomAction); + }, [sendJsonMessage, authToken]); return ( {children} From e02e8b7b6cb68e8612058f9ee3c85d507fe4c355 Mon Sep 17 00:00:00 2001 From: cjs8487 Date: Fri, 12 Dec 2025 23:14:58 -0600 Subject: [PATCH 02/16] unify timing functionality --- api/src/core/Room.ts | 61 ++++++------- api/src/core/integration/races/LocalTimer.ts | 66 ++++++++++++++ api/src/core/integration/races/RaceHandler.ts | 40 +++++++++ .../core/integration/races/RacetimeHandler.ts | 26 ++++++ api/src/routes/rooms/Rooms.ts | 16 ++-- schema/schemas/RoomData.json | 8 -- .../components/room/racetime/RacetimeCard.tsx | 2 +- .../room/racetime/RacetimeTimer.tsx | 87 ------------------- 8 files changed, 176 insertions(+), 130 deletions(-) create mode 100644 api/src/core/integration/races/LocalTimer.ts delete mode 100644 web/src/components/room/racetime/RacetimeTimer.tsx diff --git a/api/src/core/Room.ts b/api/src/core/Room.ts index 9da982c7..5db202cb 100644 --- a/api/src/core/Room.ts +++ b/api/src/core/Room.ts @@ -1,3 +1,4 @@ +import { GeneratorSettings } from '@playbingo/shared'; import { ChangeColorAction, ChatAction, @@ -38,7 +39,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 +56,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', @@ -108,9 +110,6 @@ export default class Room { exploration: boolean = false; alwaysRevealedMask: bigint = 0n; - startedAt?: string; - finishedAt?: string; - lastGenerationMode: BoardGenerationOptions; victoryMasks: bigint[]; @@ -159,7 +158,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.board = []; @@ -431,16 +434,16 @@ export default class Room { 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, + url: (this.raceHandler as RacetimeHandler).url, + startDelay: (this.raceHandler as RacetimeHandler).data + ?.start_delay, + status: (this.raceHandler as RacetimeHandler).data?.status + .verbose_value, }, mode: getModeString(this.bingoMode, this.lineCount), variant: this.variantName, - startedAt: this.startedAt, - finishedAt: this.finishedAt, + startedAt: this.raceHandler?.getStartTime(), + finishedAt: this.raceHandler?.getEndTime(), }, players: this.getPlayers(), }; @@ -595,7 +598,7 @@ export default class Room { } handleStartTimer() { - this.startedAt = new Date().toISOString(); + this.raceHandler?.startTimer(); this.sendRoomData(); } @@ -640,7 +643,7 @@ export default class Room { }); this.sendChat(`Racetime.gg room created ${url}`); this.raceHandler.connect(url); - this.raceHandler.connectWebsocket(); + (this.raceHandler as RacetimeHandler).connectWebsocket(); } handleRacetimeRoomDisconnected() { @@ -734,7 +737,7 @@ 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, @@ -757,8 +760,8 @@ export default class Room { newGenerator: this.newGenerator, mode: getModeString(this.bingoMode, this.lineCount), variant: this.variantName, - startedAt: this.startedAt, - finishedAt: this.finishedAt, + startedAt: this.raceHandler?.getStartTime(), + finishedAt: this.raceHandler?.getEndTime(), }, }); } @@ -794,7 +797,7 @@ export default class Room { ' has achieved lockout!', ]); player.goalComplete = true; - player.finishedAt = new Date().toISOString(); + this.raceHandler?.playerFinished(player); } if (player.goalComplete && player.goalCount < goalsNeeded) { this.sendChat([ @@ -805,7 +808,7 @@ export default class Room { ' no longer has lockout.', ]); player.goalComplete = false; - player.finishedAt = undefined; + this.raceHandler?.playerUnfinshed(player); } } else { if (this.bingoMode === BingoMode.LINES) { @@ -828,7 +831,7 @@ export default class Room { !player.goalComplete ) { player.goalComplete = true; - player.finishedAt = new Date().toISOString(); + this.raceHandler?.playerFinished(player); this.sendChat([ { contents: player.nickname, @@ -841,7 +844,7 @@ export default class Room { player.goalComplete ) { player.goalComplete = false; - player.finishedAt = undefined; + this.raceHandler?.playerUnfinshed(player); this.sendChat([ { contents: player.nickname, @@ -857,7 +860,7 @@ export default class Room { ); if (complete && !player.goalComplete) { player.goalComplete = true; - player.finishedAt = new Date().toISOString(); + this.raceHandler?.playerFinished(player); this.sendChat([ { contents: player.nickname, @@ -867,7 +870,7 @@ export default class Room { ]); } else if (!complete && player.goalComplete) { player.goalComplete = false; - player.finishedAt = undefined; + this.raceHandler?.playerUnfinshed(player); this.sendChat([ { contents: player.nickname, @@ -887,11 +890,11 @@ export default class Room { }); this.completed = allComplete; if (this.completed) { - this.finishedAt = new Date().toISOString(); + this.raceHandler?.allPlayersFinished(); this.sendRoomData(); } else { - if (this.finishedAt) { - this.finishedAt = undefined; + if (this.raceHandler?.getEndTime()) { + this.raceHandler?.allPlayersNotFinished(); this.sendRoomData(); } } @@ -949,7 +952,7 @@ export default class Room { //#region Racetime Integration async connectRacetimeWebSocket() { - this.raceHandler.connectWebsocket(); + (this.raceHandler as RacetimeHandler).connectWebsocket(); } joinRaceRoom(racetimeId: string, authToken: RoomTokenPayload) { diff --git a/api/src/core/integration/races/LocalTimer.ts b/api/src/core/integration/races/LocalTimer.ts new file mode 100644 index 00000000..cd7b53cd --- /dev/null +++ b/api/src/core/integration/races/LocalTimer.ts @@ -0,0 +1,66 @@ +import { RaceStatusConnected } from '@playbingo/types'; +import RaceHandler from './RaceHandler'; +import Player from '../../Player'; + +export default class LocalTimer implements RaceHandler { + startedAt?: string; + finishedAt?: string; + + constructor() {} + + connect(url: string): void {} + + disconnect(): void {} + + async joinPlayer(token: string): Promise { + return true; + } + + async leavePlayer(token: string): Promise { + return true; + } + + async readyPlayer(token: string): Promise { + return true; + } + + async unreadyPlayer(token: string): Promise { + throw new Error('Method not implemented.'); + } + + async refresh(): Promise {} + + getPlayer(id: string): Omit | undefined { + return { + username: id, + }; + } + + getStartTime(): string | undefined { + return this.startedAt; + } + + getEndTime(): string | undefined { + return this.finishedAt; + } + + startTimer(): void { + this.startedAt = new Date().toISOString(); + } + + async playerFinished(player: Player): Promise { + player.finishedAt = new Date().toISOString(); + } + + async playerUnfinshed(player: Player): Promise { + player.finishedAt = undefined; + } + + async allPlayersFinished(): Promise { + this.finishedAt = new Date().toISOString(); + } + + async allPlayersNotFinished(): Promise { + this.finishedAt = undefined; + } +} diff --git a/api/src/core/integration/races/RaceHandler.ts b/api/src/core/integration/races/RaceHandler.ts index 21ae18a2..6b19483c 100644 --- a/api/src/core/integration/races/RaceHandler.ts +++ b/api/src/core/integration/races/RaceHandler.ts @@ -1,4 +1,5 @@ import { RaceStatusConnected } from '@playbingo/types'; +import Player from '../../Player'; /** * Represents an arbitrary connection to a service that tracks racing status. @@ -59,4 +60,43 @@ export default interface RaceHandler { * @param id The service id of the player */ getPlayer(id: string): 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; + + /** + * 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..bdb30562 100644 --- a/api/src/core/integration/races/RacetimeHandler.ts +++ b/api/src/core/integration/races/RacetimeHandler.ts @@ -4,6 +4,7 @@ import { disconnectRoomFromRacetime } from '../../../database/Rooms'; import Room from '../../Room'; import { logInfo } from '../../../Logger'; import RaceHandler from './RaceHandler'; +import Player from '../../Player'; interface User { id: string; @@ -366,4 +367,29 @@ 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; + } + + startTimer(): void { + throw new Error('Method not implemented.'); + } + + async playerFinished(player: Player): Promise { + throw new Error('Method not implemented.'); + } + + async playerUnfinshed(player: Player): Promise { + throw new Error('Method not implemented.'); + } + + // 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/routes/rooms/Rooms.ts b/api/src/routes/rooms/Rooms.ts index 4c91680e..a937882a 100644 --- a/api/src/routes/rooms/Rooms.ts +++ b/api/src/routes/rooms/Rooms.ts @@ -28,6 +28,7 @@ import { getCategories } from '../../database/games/GoalCategories'; import { getVariant } from '../../database/games/Variants'; import { DifficultyVariant, Variant } from '@prisma/client'; import { GenerationFailedError } from '../../core/generation/GenerationFailedError'; +import RacetimeHandler from '../../core/integration/races/RacetimeHandler'; const MIN_ROOM_GOALS_REQUIRED = 25; const rooms = Router(); @@ -423,11 +424,16 @@ 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, + started: + (room.raceHandler as RacetimeHandler).data?.started_at ?? + undefined, + ended: + (room.raceHandler as RacetimeHandler).data?.ended_at ?? + undefined, + status: (room.raceHandler as RacetimeHandler).data?.status + .verbose_value, }, mode: room.bingoMode, variant: room.variantName, diff --git a/schema/schemas/RoomData.json b/schema/schemas/RoomData.json index ebf6b90b..5f705914 100644 --- a/schema/schemas/RoomData.json +++ b/schema/schemas/RoomData.json @@ -72,14 +72,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/web/src/components/room/racetime/RacetimeCard.tsx b/web/src/components/room/racetime/RacetimeCard.tsx index abb36d3f..46c55409 100644 --- a/web/src/components/room/racetime/RacetimeCard.tsx +++ b/web/src/components/room/racetime/RacetimeCard.tsx @@ -11,7 +11,7 @@ import { import NextLink from 'next/link'; import { useRoomContext } from '../../../context/RoomContext'; import { useUserContext } from '../../../context/UserContext'; -import Timer from './RacetimeTimer'; +import Timer from '../Timer'; export default function RacetimeCard() { const { diff --git a/web/src/components/room/racetime/RacetimeTimer.tsx b/web/src/components/room/racetime/RacetimeTimer.tsx deleted file mode 100644 index 861f1f9a..00000000 --- a/web/src/components/room/racetime/RacetimeTimer.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import { Typography } from '@mui/material'; -import { DateTime, Duration } from 'luxon'; -import { useContext, useEffect, useState } from 'react'; -import { useInterval } from 'react-use'; -import { RoomContext } from '../../../context/RoomContext'; - -export default function RacetimeTimer() { - const { roomData } = useContext(RoomContext); - - if (!roomData || !roomData.racetimeConnection) { - return null; - } - - const { - racetimeConnection: { startDelay, started, ended }, - } = roomData; - - if (!startDelay) { - return null; - } - - let startDt: DateTime | undefined; - if (started) { - startDt = DateTime.fromISO(started); - } - let endDt: DateTime | undefined; - if (ended) { - endDt = DateTime.fromISO(ended); - } - 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, - ); - - let interval; - if (updateTimer) { - if (end) { - interval = null; - } else if (start) { - interval = 10; - } else { - interval = 1000; - } - } else { - interval = null; - } - - useInterval(() => { - if (end && start) { - setDur(end.diff(start)); - } else if (start) { - setDur(DateTime.now().diff(start).normalize()); - } - }, interval); - - useEffect(() => { - const callback = () => { - if (document.hidden) { - setUpdateTimer(false); - } else { - setUpdateTimer(true); - } - }; - document.addEventListener('visibilitychange', callback); - return () => document.removeEventListener('visibilitychange', callback); - }, []); - - return ( - - {dur.toFormat('h:mm:ss')}.{dur.milliseconds % 10} - - ); -} From 2962409cd48bbe264d8a1be12bb598597b8f7b36 Mon Sep 17 00:00:00 2001 From: cjs8487 Date: Sat, 13 Dec 2025 16:40:50 -0600 Subject: [PATCH 03/16] convert to player based api instead of token based for race actions --- api/src/core/Player.ts | 41 ++------- api/src/core/Room.ts | 1 - api/src/core/integration/races/LocalTimer.ts | 14 +-- api/src/core/integration/races/RaceHandler.ts | 31 ++++--- .../core/integration/races/RacetimeHandler.ts | 86 ++++++++++++------- web/src/app/(main)/rooms/[slug]/page.tsx | 2 +- 6 files changed, 88 insertions(+), 87 deletions(-) diff --git a/api/src/core/Player.ts b/api/src/core/Player.ts index 141ef9bd..315dbda6 100644 --- a/api/src/core/Player.ts +++ b/api/src/core/Player.ts @@ -62,7 +62,6 @@ export default class Player { connections: Map; raceHandler: RaceHandler; - raceId: string; finishedAt?: string; @@ -91,7 +90,6 @@ export default class Player { this.connections = new Map(); this.raceHandler = room.raceHandler; - this.raceId = ''; } doesTokenMatch(token: RoomTokenPayload) { @@ -155,7 +153,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.raceHandler.getPlayer(this); return { id: this.id, nickname: this.nickname, @@ -305,48 +303,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.raceHandler.joinPlayer(this); } async leaveRace() { - return this.tryRaceAction( - this.raceHandler.leavePlayer.bind(this.raceHandler), - 'Unable to leave race room', - ); + return this.raceHandler.leavePlayer(this); } async ready() { - return this.tryRaceAction( - this.raceHandler.readyPlayer.bind(this.raceHandler), - 'Unable to ready in race room', - ); + return this.raceHandler.readyPlayer(this); } async unready() { - return this.tryRaceAction( - this.raceHandler.unreadyPlayer.bind(this.raceHandler), - 'Unable to unready in race room', - ); + return this.raceHandler.unreadyPlayer(this); } //#endregion } diff --git a/api/src/core/Room.ts b/api/src/core/Room.ts index 5db202cb..03dce02f 100644 --- a/api/src/core/Room.ts +++ b/api/src/core/Room.ts @@ -962,7 +962,6 @@ export default class Room { return false; } this.logInfo(`Connecting ${player.nickname} to racetime`); - player.raceId = racetimeId; return player.joinRace(); } diff --git a/api/src/core/integration/races/LocalTimer.ts b/api/src/core/integration/races/LocalTimer.ts index cd7b53cd..468baff3 100644 --- a/api/src/core/integration/races/LocalTimer.ts +++ b/api/src/core/integration/races/LocalTimer.ts @@ -12,27 +12,29 @@ export default class LocalTimer implements RaceHandler { disconnect(): void {} - async joinPlayer(token: string): Promise { + async joinPlayer(player: Player): Promise { return true; } - async leavePlayer(token: string): Promise { + async leavePlayer(player: Player): Promise { return true; } - async readyPlayer(token: string): Promise { + async readyPlayer(player: Player): Promise { return true; } - async unreadyPlayer(token: string): Promise { + async unreadyPlayer(player: Player): Promise { throw new Error('Method not implemented.'); } async refresh(): Promise {} - getPlayer(id: string): Omit | undefined { + getPlayer( + player: Player, + ): Omit | undefined { return { - username: id, + username: player.id, }; } diff --git a/api/src/core/integration/races/RaceHandler.ts b/api/src/core/integration/races/RaceHandler.ts index 6b19483c..e0ccab4d 100644 --- a/api/src/core/integration/races/RaceHandler.ts +++ b/api/src/core/integration/races/RaceHandler.ts @@ -6,12 +6,19 @@ import Player from '../../Player'; * 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. */ @@ -31,22 +38,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 @@ -59,7 +66,9 @@ 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 diff --git a/api/src/core/integration/races/RacetimeHandler.ts b/api/src/core/integration/races/RacetimeHandler.ts index bdb30562..334d0efc 100644 --- a/api/src/core/integration/races/RacetimeHandler.ts +++ b/api/src/core/integration/races/RacetimeHandler.ts @@ -5,6 +5,7 @@ import Room from '../../Room'; import { logInfo } from '../../../Logger'; import RaceHandler from './RaceHandler'; import Player from '../../Player'; +import { getAccessToken } from '../../../lib/RacetimeConnector'; interface User { id: string; @@ -104,6 +105,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< @@ -141,31 +143,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 @@ -250,8 +267,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, @@ -262,14 +283,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; } @@ -283,7 +305,7 @@ export default class RacetimeHandler implements RaceHandler { } } - async leavePlayer(token: string): Promise { + async leavePlayer(player: Player): Promise { throw new Error('Not implemented'); } @@ -330,7 +352,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', @@ -338,7 +360,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) { @@ -349,7 +371,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', @@ -357,7 +379,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) { diff --git a/web/src/app/(main)/rooms/[slug]/page.tsx b/web/src/app/(main)/rooms/[slug]/page.tsx index bdae565f..dd2848c6 100644 --- a/web/src/app/(main)/rooms/[slug]/page.tsx +++ b/web/src/app/(main)/rooms/[slug]/page.tsx @@ -311,7 +311,7 @@ function RoomXl() { - + {/* */} From 201b149848bc44781bc0087eb5b99539b0417d8e Mon Sep 17 00:00:00 2001 From: cjs8487 Date: Sat, 13 Dec 2025 17:21:37 -0600 Subject: [PATCH 04/16] unify display logic for timing --- api/src/core/Room.ts | 18 +++++--- api/src/routes/rooms/Rooms.ts | 6 --- schema/types/RoomData.d.ts | 8 ---- schema/types/ServerMessage.d.ts | 8 ---- web/src/app/(main)/rooms/[slug]/page.tsx | 6 +-- .../components/room/racetime/RacetimeCard.tsx | 8 ++-- web/src/components/room/timer/Timer.tsx | 31 +++++++++++++ .../{Timer.tsx => timer/TimerDisplay.tsx} | 44 +++++-------------- 8 files changed, 61 insertions(+), 68 deletions(-) create mode 100644 web/src/components/room/timer/Timer.tsx rename web/src/components/room/{Timer.tsx => timer/TimerDisplay.tsx} (56%) diff --git a/api/src/core/Room.ts b/api/src/core/Room.ts index 03dce02f..9a3783cc 100644 --- a/api/src/core/Room.ts +++ b/api/src/core/Room.ts @@ -739,11 +739,10 @@ export default class Room { gameActive: this.racetimeEligible, 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() { @@ -754,9 +753,18 @@ export default class Room { slug: this.slug, name: this.name, gameSlug: this.gameSlug, - racetimeConnection: { - url: undefined, - }, + 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, diff --git a/api/src/routes/rooms/Rooms.ts b/api/src/routes/rooms/Rooms.ts index a937882a..4888e366 100644 --- a/api/src/routes/rooms/Rooms.ts +++ b/api/src/routes/rooms/Rooms.ts @@ -426,12 +426,6 @@ rooms.get('/:slug', async (req, res) => { gameActive: room.racetimeEligible, url: (room.raceHandler as RacetimeHandler).url, startDelay: (room.raceHandler as RacetimeHandler).data?.start_delay, - started: - (room.raceHandler as RacetimeHandler).data?.started_at ?? - undefined, - ended: - (room.raceHandler as RacetimeHandler).data?.ended_at ?? - undefined, status: (room.raceHandler as RacetimeHandler).data?.status .verbose_value, }, diff --git a/schema/types/RoomData.d.ts b/schema/types/RoomData.d.ts index 40ad3cf3..5b80293e 100644 --- a/schema/types/RoomData.d.ts +++ b/schema/types/RoomData.d.ts @@ -45,12 +45,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 9fc77a4b..9a9b3223 100644 --- a/schema/types/ServerMessage.d.ts +++ b/schema/types/ServerMessage.d.ts @@ -148,14 +148,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/src/app/(main)/rooms/[slug]/page.tsx b/web/src/app/(main)/rooms/[slug]/page.tsx index dd2848c6..86db28e4 100644 --- a/web/src/app/(main)/rooms/[slug]/page.tsx +++ b/web/src/app/(main)/rooms/[slug]/page.tsx @@ -6,7 +6,7 @@ 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'; +import Timer from '@/components/room/timer/Timer'; import { ConnectionStatus, useRoomContext } from '@/context/RoomContext'; import { Box, Dialog, DialogContent, Stack } from '@mui/material'; @@ -259,7 +259,6 @@ function RoomLg() { - - - {/* */} + diff --git a/web/src/components/room/racetime/RacetimeCard.tsx b/web/src/components/room/racetime/RacetimeCard.tsx index 46c55409..95e8c7e2 100644 --- a/web/src/components/room/racetime/RacetimeCard.tsx +++ b/web/src/components/room/racetime/RacetimeCard.tsx @@ -8,10 +8,11 @@ import { Link, Typography, } from '@mui/material'; +import { Duration } from 'luxon'; import NextLink from 'next/link'; import { useRoomContext } from '../../../context/RoomContext'; import { useUserContext } from '../../../context/UserContext'; -import Timer from '../Timer'; +import TimerDisplay from '../timer/TimerDisplay'; export default function RacetimeCard() { const { @@ -34,10 +35,11 @@ export default function RacetimeCard() { return null; } - const { gameActive, url, status } = racetimeConnection; + const { gameActive, url, status, startDelay } = racetimeConnection; if (!gameActive) { return null; } + const offset = Duration.fromISO(startDelay ?? ''); return ( @@ -113,7 +115,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..48b1b317 --- /dev/null +++ b/web/src/components/room/timer/Timer.tsx @@ -0,0 +1,31 @@ +import { RoomContext } from '@/context/RoomContext'; +import { Button, 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 } = useContext(RoomContext); + + if (!roomData) { + return null; + } + + const { racetimeConnection } = roomData; + + if (racetimeConnection) { + return ; + } + + const offset = Duration.fromDurationLike(0); + + return ( + + + + + + + ); +} diff --git a/web/src/components/room/Timer.tsx b/web/src/components/room/timer/TimerDisplay.tsx similarity index 56% rename from web/src/components/room/Timer.tsx rename to web/src/components/room/timer/TimerDisplay.tsx index 73e107d4..dcff77d7 100644 --- a/web/src/components/room/Timer.tsx +++ b/web/src/components/room/timer/TimerDisplay.tsx @@ -1,47 +1,23 @@ -import { Button, Card, CardContent, Typography } from '@mui/material'; +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, startTimer } = useContext(RoomContext); +export default function TimerDisplay({ offset }: { offset: Duration }) { + const { roomData } = useRoomContext(); - if (!roomData) { - return null; - } - - const { startedAt, finishedAt } = roomData; + const { startedAt, finishedAt } = roomData!; - let startDt: DateTime | undefined; + let start: DateTime | undefined; if (startedAt) { - startDt = DateTime.fromISO(startedAt); + start = DateTime.fromISO(startedAt); } - let endDt: DateTime | undefined; + let end: DateTime | undefined; if (finishedAt) { - endDt = DateTime.fromISO(finishedAt); + end = DateTime.fromISO(finishedAt); } - const offset = Duration.fromDurationLike(0); - - 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, From 9e039bcd2fdfa8d8b05a51907c59121c27638e6c Mon Sep 17 00:00:00 2001 From: cjs8487 Date: Sun, 14 Dec 2025 01:11:43 -0600 Subject: [PATCH 05/16] allow monitors to toggle between basic timer and rtgg --- api/src/core/Room.ts | 20 ++++++ api/src/core/RoomServer.ts | 6 +- api/src/core/integration/races/LocalTimer.ts | 4 ++ api/src/core/integration/races/RaceHandler.ts | 4 ++ .../core/integration/races/RacetimeHandler.ts | 4 ++ schema/schemas/RoomAction.json | 12 +++- schema/schemas/RoomData.json | 3 + schema/types/RoomAction.d.ts | 5 ++ schema/types/RoomData.d.ts | 1 + schema/types/ServerMessage.d.ts | 1 + web/public/rtgg128.png | Bin 0 -> 9045 bytes web/src/components/room/RoomControlDialog.tsx | 57 +++++++++++++++++- web/src/context/RoomContext.tsx | 13 ++++ 13 files changed, 125 insertions(+), 5 deletions(-) create mode 100644 web/public/rtgg128.png diff --git a/api/src/core/Room.ts b/api/src/core/Room.ts index 9a3783cc..36b1813a 100644 --- a/api/src/core/Room.ts +++ b/api/src/core/Room.ts @@ -1,6 +1,7 @@ import { GeneratorSettings } from '@playbingo/shared'; import { ChangeColorAction, + ChangeRaceHandlerAction, ChatAction, ChatMessage, JoinAction, @@ -444,6 +445,7 @@ export default class Room { variant: this.variantName, startedAt: this.raceHandler?.getStartTime(), finishedAt: this.raceHandler?.getEndTime(), + raceHandler: this.raceHandler?.key(), }, players: this.getPlayers(), }; @@ -602,6 +604,21 @@ export default class Room { this.sendRoomData(); } + handleChangeRaceHandler(action: ChangeRaceHandlerAction) { + if (this.raceHandler) { + this.raceHandler.disconnect(); + } + switch (action.raceHandler) { + case 'local': + this.raceHandler = new LocalTimer(); + break; + case 'racetime': + this.raceHandler = new RacetimeHandler(this); + break; + } + this.sendRoomData(); + } + handleSocketClose(ws: WebSocket) { let player: Player | undefined; for (const p of this.players.values()) { @@ -639,6 +656,7 @@ 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}`); @@ -661,6 +679,7 @@ export default class Room { newGenerator: this.newGenerator, mode: getModeString(this.bingoMode, this.lineCount), variant: this.variantName, + raceHandler: this.raceHandler?.key(), }, }); } @@ -770,6 +789,7 @@ export default class Room { variant: this.variantName, startedAt: this.raceHandler?.getStartTime(), finishedAt: this.raceHandler?.getEndTime(), + raceHandler: this.raceHandler?.key(), }, }); } diff --git a/api/src/core/RoomServer.ts b/api/src/core/RoomServer.ts index 1c6332a3..9aef6918 100644 --- a/api/src/core/RoomServer.ts +++ b/api/src/core/RoomServer.ts @@ -151,10 +151,12 @@ roomWebSocketServer.on('connection', (ws, req) => { } } break; - case 'startTimer': { + case 'startTimer': room.handleStartTimer(); break; - } + case 'changeRaceHandler': + room.handleChangeRaceHandler(action); + break; } }); ws.on('close', (code, reason) => { diff --git a/api/src/core/integration/races/LocalTimer.ts b/api/src/core/integration/races/LocalTimer.ts index 468baff3..dd183ec3 100644 --- a/api/src/core/integration/races/LocalTimer.ts +++ b/api/src/core/integration/races/LocalTimer.ts @@ -8,6 +8,10 @@ export default class LocalTimer implements RaceHandler { constructor() {} + key(): 'local' | 'racetime' { + return 'local'; + } + connect(url: string): void {} disconnect(): void {} diff --git a/api/src/core/integration/races/RaceHandler.ts b/api/src/core/integration/races/RaceHandler.ts index e0ccab4d..fdf8c603 100644 --- a/api/src/core/integration/races/RaceHandler.ts +++ b/api/src/core/integration/races/RaceHandler.ts @@ -23,6 +23,10 @@ import Player from '../../Player'; * This interface is an implementation of the adapter design pattern. */ export default interface RaceHandler { + /** + * Returns a unique key for the race handler + */ + key(): 'local' | 'racetime'; /** * Connects the bingo room to the race room * diff --git a/api/src/core/integration/races/RacetimeHandler.ts b/api/src/core/integration/races/RacetimeHandler.ts index 334d0efc..34a40d95 100644 --- a/api/src/core/integration/races/RacetimeHandler.ts +++ b/api/src/core/integration/races/RacetimeHandler.ts @@ -119,6 +119,10 @@ export default class RacetimeHandler implements RaceHandler { this.room = room; } + key(): 'local' | 'racetime' { + return 'racetime'; + } + //#region Synchronous Websocket Functions async ping() { return new Promise((resolve, reject) => { diff --git a/schema/schemas/RoomAction.json b/schema/schemas/RoomAction.json index 99ad0bf4..c9f26821 100644 --- a/schema/schemas/RoomAction.json +++ b/schema/schemas/RoomAction.json @@ -17,7 +17,9 @@ {"$ref": "#/$defs/NewCardAction"}, {"$ref": "#/$defs/RevealCardAction"}, {"$ref": "#/$defs/ChangeAuthAction"}, - {"$ref": "#/$defs/StartTimerAction"} + {"$ref": "#/$defs/StartTimerAction"}, + {"$ref": "#/$defs/ChangeRaceHandlerAction"} + ], "$defs": { "JoinAction": { @@ -142,6 +144,14 @@ "properties": { "action": "startTimer" } + }, + "ChangeRaceHandlerAction": { + "required": ["action", "raceHandler"], + "additionalProperties": false, + "properties": { + "action": "changeRaceHandler", + "raceHandler": {"enum": ["racetime", "local"]} + } } } } \ No newline at end of file diff --git a/schema/schemas/RoomData.json b/schema/schemas/RoomData.json index 5f705914..910ce70e 100644 --- a/schema/schemas/RoomData.json +++ b/schema/schemas/RoomData.json @@ -46,6 +46,9 @@ }, "finishedAt": { "type": "string" + }, + "raceHandler": { + "enum": ["local", "racetime"] } }, "$defs": { diff --git a/schema/types/RoomAction.d.ts b/schema/types/RoomAction.d.ts index b20230a7..c24e0bf8 100644 --- a/schema/types/RoomAction.d.ts +++ b/schema/types/RoomAction.d.ts @@ -19,6 +19,7 @@ export type RoomAction = ( | RevealCardAction | ChangeAuthAction | StartTimerAction + | ChangeRaceHandlerAction ) & { /** * JWT for the room obtained from the server @@ -81,3 +82,7 @@ export interface ChangeAuthAction { export interface StartTimerAction { action: "startTimer"; } +export interface ChangeRaceHandlerAction { + action: "changeRaceHandler"; + raceHandler: "racetime" | "local"; +} diff --git a/schema/types/RoomData.d.ts b/schema/types/RoomData.d.ts index 5b80293e..3891f837 100644 --- a/schema/types/RoomData.d.ts +++ b/schema/types/RoomData.d.ts @@ -23,6 +23,7 @@ export interface RoomData { mode: string; startedAt?: string; finishedAt?: string; + raceHandler?: "local" | "racetime"; } export interface RacetimeConnection { /** diff --git a/schema/types/ServerMessage.d.ts b/schema/types/ServerMessage.d.ts index 9a9b3223..762671a2 100644 --- a/schema/types/ServerMessage.d.ts +++ b/schema/types/ServerMessage.d.ts @@ -126,6 +126,7 @@ export interface RoomData { mode: string; startedAt?: string; finishedAt?: string; + raceHandler?: "local" | "racetime"; } export interface RacetimeConnection { /** diff --git a/web/public/rtgg128.png b/web/public/rtgg128.png new file mode 100644 index 0000000000000000000000000000000000000000..3d45ae2df86beece9787c6da350e215119d553b3 GIT binary patch literal 9045 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 Ju8@Bl_J7|~mD>OS literal 0 HcmV?d00001 diff --git a/web/src/components/room/RoomControlDialog.tsx b/web/src/components/room/RoomControlDialog.tsx index 453b512d..c2730931 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 - void; changeAuth: (spectate: boolean) => void; startTimer: () => void; + changeRaceHandler: (handler: string) => void; } export const RoomContext = createContext({ @@ -115,6 +116,7 @@ export const RoomContext = createContext({ toggleCounters() {}, changeAuth() {}, startTimer() {}, + changeRaceHandler() {}, }); interface RoomContextProps { @@ -508,6 +510,16 @@ export function RoomContextProvider({ authToken, } as RoomAction); }, [sendJsonMessage, authToken]); + const changeRaceHandler = useCallback( + (handler: string) => { + sendJsonMessage({ + action: 'changeRaceHandler', + authToken, + raceHandler: handler, + } as RoomAction); + }, + [authToken, sendJsonMessage], + ); return ( {children} From 3b4ba1c2e873e9a7df85c4f682a70116a52006eb Mon Sep 17 00:00:00 2001 From: cjs8487 Date: Sun, 14 Dec 2025 13:27:00 -0600 Subject: [PATCH 06/16] send rt done/undone when player goal completion changes --- api/src/core/Room.ts | 6 +++- .../core/integration/races/RacetimeHandler.ts | 31 +++++++++++++++++-- 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/api/src/core/Room.ts b/api/src/core/Room.ts index 36b1813a..e599fe5b 100644 --- a/api/src/core/Room.ts +++ b/api/src/core/Room.ts @@ -16,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, @@ -1021,6 +1021,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 }); } diff --git a/api/src/core/integration/races/RacetimeHandler.ts b/api/src/core/integration/races/RacetimeHandler.ts index 34a40d95..1a60610c 100644 --- a/api/src/core/integration/races/RacetimeHandler.ts +++ b/api/src/core/integration/races/RacetimeHandler.ts @@ -243,6 +243,7 @@ export default class RacetimeHandler implements RaceHandler { handleWebsocketMessage(data: RawData) { const message: WebSocketMessage = JSON.parse(data.toString()); + this.room.logDebug(`racetime.gg ws message: ${message.type}`); switch (message.type) { case 'pong': if (this.nextPongCallback) { @@ -407,11 +408,37 @@ export default class RacetimeHandler implements RaceHandler { } async playerFinished(player: Player): Promise { - throw new Error('Method not implemented.'); + 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 { - throw new Error('Method not implemented.'); + 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 From 58c4297b3fc6953c9f163e55ce3012d70cceaa09 Mon Sep 17 00:00:00 2001 From: cjs8487 Date: Sun, 14 Dec 2025 13:41:30 -0600 Subject: [PATCH 07/16] starting the timer in basic timing mode will now reveal card --- api/src/core/Room.ts | 47 ++++++++++++-------- api/src/core/integration/races/LocalTimer.ts | 7 ++- 2 files changed, 35 insertions(+), 19 deletions(-) diff --git a/api/src/core/Room.ts b/api/src/core/Room.ts index e599fe5b..91fbbf31 100644 --- a/api/src/core/Room.ts +++ b/api/src/core/Room.ts @@ -162,7 +162,7 @@ export default class Room { if (this.racetimeEligible) { this.raceHandler = new RacetimeHandler(this); } else { - this.raceHandler = new LocalTimer(); + this.raceHandler = new LocalTimer(this); } this.board = []; @@ -610,7 +610,7 @@ export default class Room { } switch (action.raceHandler) { case 'local': - this.raceHandler = new LocalTimer(); + this.raceHandler = new LocalTimer(this); break; case 'racetime': this.raceHandler = new RacetimeHandler(this); @@ -689,22 +689,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 @@ -1072,6 +1057,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/integration/races/LocalTimer.ts b/api/src/core/integration/races/LocalTimer.ts index dd183ec3..275f34f2 100644 --- a/api/src/core/integration/races/LocalTimer.ts +++ b/api/src/core/integration/races/LocalTimer.ts @@ -1,12 +1,16 @@ import { RaceStatusConnected } from '@playbingo/types'; import RaceHandler from './RaceHandler'; import Player from '../../Player'; +import Room from '../../Room'; export default class LocalTimer implements RaceHandler { startedAt?: string; finishedAt?: string; + room: Room; - constructor() {} + constructor(room: Room) { + this.room = room; + } key(): 'local' | 'racetime' { return 'local'; @@ -52,6 +56,7 @@ export default class LocalTimer implements RaceHandler { startTimer(): void { this.startedAt = new Date().toISOString(); + this.room.revealCardForAllPlayers(); } async playerFinished(player: Player): Promise { From 20b135521d7829b57e12b27638deeafe292e51e2 Mon Sep 17 00:00:00 2001 From: cjs8487 Date: Sun, 14 Dec 2025 14:55:10 -0600 Subject: [PATCH 08/16] auto reveal card in racetime integration --- api/src/core/integration/races/RacetimeHandler.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/api/src/core/integration/races/RacetimeHandler.ts b/api/src/core/integration/races/RacetimeHandler.ts index 1a60610c..52038e5d 100644 --- a/api/src/core/integration/races/RacetimeHandler.ts +++ b/api/src/core/integration/races/RacetimeHandler.ts @@ -232,6 +232,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); } From 001df36c9e7fa1a9e5580aca0f96de006011bb3b Mon Sep 17 00:00:00 2001 From: cjs8487 Date: Sun, 14 Dec 2025 15:41:56 -0600 Subject: [PATCH 09/16] add ability to leave rt room --- api/src/core/Room.ts | 10 ++++++++ .../core/integration/races/RacetimeHandler.ts | 22 ++++++++++++++++- .../routes/rooms/actions/RacetimeActions.ts | 24 +++++++++++++++++++ .../components/room/racetime/RacetimeCard.tsx | 6 +++++ web/src/context/RoomContext.tsx | 20 ++++++++++++++++ 5 files changed, 81 insertions(+), 1 deletion(-) diff --git a/api/src/core/Room.ts b/api/src/core/Room.ts index 91fbbf31..aa430b82 100644 --- a/api/src/core/Room.ts +++ b/api/src/core/Room.ts @@ -978,6 +978,16 @@ export default class Room { 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(); } diff --git a/api/src/core/integration/races/RacetimeHandler.ts b/api/src/core/integration/races/RacetimeHandler.ts index 52038e5d..79f004ce 100644 --- a/api/src/core/integration/races/RacetimeHandler.ts +++ b/api/src/core/integration/races/RacetimeHandler.ts @@ -272,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; @@ -322,7 +323,26 @@ export default class RacetimeHandler implements RaceHandler { } async leavePlayer(player: Player): Promise { - throw new Error('Not implemented'); + 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() { 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/web/src/components/room/racetime/RacetimeCard.tsx b/web/src/components/room/racetime/RacetimeCard.tsx index 95e8c7e2..aa7b873e 100644 --- a/web/src/components/room/racetime/RacetimeCard.tsx +++ b/web/src/components/room/racetime/RacetimeCard.tsx @@ -23,6 +23,7 @@ export default function RacetimeCard() { racetimeReady, racetimeUnready, connectedPlayer, + leaveRacetimeRoom, } = useRoomContext(); const { loggedIn, user } = useUserContext(); @@ -105,6 +106,11 @@ export default function RacetimeCard() { Ready )} + )} diff --git a/web/src/context/RoomContext.tsx b/web/src/context/RoomContext.tsx index 3ab051c1..c7b5324e 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; @@ -108,6 +109,7 @@ export const RoomContext = createContext({ createRacetimeRoom() {}, updateRacetimeRoom() {}, joinRacetimeRoom() {}, + leaveRacetimeRoom() {}, racetimeReady() {}, racetimeUnready() {}, toggleGoalStar() {}, @@ -444,6 +446,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; @@ -546,6 +565,7 @@ export function RoomContextProvider({ createRacetimeRoom, updateRacetimeRoom, joinRacetimeRoom, + leaveRacetimeRoom, racetimeReady, racetimeUnready, toggleGoalStar, From 9c6f8ddca96b66d46d8ba97979242fd8ec56c252 Mon Sep 17 00:00:00 2001 From: cjs8487 Date: Mon, 15 Dec 2025 22:27:10 -0600 Subject: [PATCH 10/16] show player finish times regardless of provider --- api/src/core/Player.ts | 22 ++----- api/src/core/Room.ts | 2 +- api/src/core/integration/races/LocalTimer.ts | 1 + web/src/components/room/PlayerList.tsx | 27 +++----- web/src/components/room/PlayerRaceSummary.tsx | 63 +++++++++++++++++++ 5 files changed, 80 insertions(+), 35 deletions(-) create mode 100644 web/src/components/room/PlayerRaceSummary.tsx diff --git a/api/src/core/Player.ts b/api/src/core/Player.ts index 315dbda6..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,6 @@ export default class Player { * is authorized for the connection */ connections: Map; - raceHandler: RaceHandler; - finishedAt?: string; constructor( @@ -88,8 +80,6 @@ export default class Player { this.exploredGoals = 0n; this.connections = new Map(); - - this.raceHandler = room.raceHandler; } 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); + const raceUser = this.room.raceHandler.getPlayer(this); return { id: this.id, nickname: this.nickname, @@ -304,18 +294,18 @@ export default class Player { //#region Races async joinRace() { - return this.raceHandler.joinPlayer(this); + return this.room.raceHandler.joinPlayer(this); } async leaveRace() { - return this.raceHandler.leavePlayer(this); + return this.room.raceHandler.leavePlayer(this); } async ready() { - return this.raceHandler.readyPlayer(this); + return this.room.raceHandler.readyPlayer(this); } async unready() { - return this.raceHandler.unreadyPlayer(this); + return this.room.raceHandler.unreadyPlayer(this); } //#endregion } diff --git a/api/src/core/Room.ts b/api/src/core/Room.ts index aa430b82..95cf47df 100644 --- a/api/src/core/Room.ts +++ b/api/src/core/Room.ts @@ -844,7 +844,7 @@ export default class Room { !player.goalComplete ) { player.goalComplete = true; - this.raceHandler?.playerFinished(player); + this.raceHandler?.playerFinished(player).then(); this.sendChat([ { contents: player.nickname, diff --git a/api/src/core/integration/races/LocalTimer.ts b/api/src/core/integration/races/LocalTimer.ts index 275f34f2..1857dca1 100644 --- a/api/src/core/integration/races/LocalTimer.ts +++ b/api/src/core/integration/races/LocalTimer.ts @@ -43,6 +43,7 @@ export default class LocalTimer implements RaceHandler { ): Omit | undefined { return { username: player.id, + finishTime: player.finishedAt, }; } diff --git a/web/src/components/room/PlayerList.tsx b/web/src/components/room/PlayerList.tsx index e6226689..0b14b572 100644 --- a/web/src/components/room/PlayerList.tsx +++ b/web/src/components/room/PlayerList.tsx @@ -1,14 +1,16 @@ 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); + + console.log(players); + 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..21796d14 --- /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} + + ); +} From 14a1bc47c722ff2734e6b3406088961af7ae61c1 Mon Sep 17 00:00:00 2001 From: cjs8487 Date: Fri, 19 Dec 2025 23:06:00 -0600 Subject: [PATCH 11/16] make timer prettier --- web/src/components/room/PlayerList.tsx | 2 -- web/src/components/room/timer/Timer.tsx | 25 +++++++++++++++++-- .../components/room/timer/TimerDisplay.tsx | 5 ++-- 3 files changed, 26 insertions(+), 6 deletions(-) diff --git a/web/src/components/room/PlayerList.tsx b/web/src/components/room/PlayerList.tsx index 0b14b572..7a30c5b6 100644 --- a/web/src/components/room/PlayerList.tsx +++ b/web/src/components/room/PlayerList.tsx @@ -9,8 +9,6 @@ export default function PlayerList() { const players = allPlayers.filter((p) => !p.spectator); const spectators = allPlayers.filter((p) => p.spectator); - console.log(players); - return ( - + + + {started ? ( + + ) : ( + + )} + ); diff --git a/web/src/components/room/timer/TimerDisplay.tsx b/web/src/components/room/timer/TimerDisplay.tsx index dcff77d7..b56fbe70 100644 --- a/web/src/components/room/timer/TimerDisplay.tsx +++ b/web/src/components/room/timer/TimerDisplay.tsx @@ -57,8 +57,9 @@ export default function TimerDisplay({ offset }: { offset: Duration }) { }, []); return ( - - {dur.toFormat('h:mm:ss')}.{dur.milliseconds % 10} + + {dur.shiftToAll().toFormat('h:mm:ss')}. + {Math.floor((dur.milliseconds % 1000) / 100)} ); } From 6b2b8cfa10c2eea11f8349e4f2c5953980968647 Mon Sep 17 00:00:00 2001 From: cjs8487 Date: Sat, 20 Dec 2025 11:34:04 -0500 Subject: [PATCH 12/16] add timer reset --- api/src/auth/RoomAuth.ts | 2 ++ api/src/core/Room.ts | 5 +++++ api/src/core/RoomServer.ts | 3 +++ api/src/core/integration/races/LocalTimer.ts | 8 ++++++++ api/src/core/integration/races/RaceHandler.ts | 5 +++++ api/src/core/integration/races/RacetimeHandler.ts | 6 +++--- schema/schemas/RoomAction.json | 10 +++++++++- schema/types/RoomAction.d.ts | 4 ++++ web/src/components/room/timer/Timer.tsx | 8 ++++++-- web/src/components/room/timer/TimerDisplay.tsx | 2 ++ web/src/context/RoomContext.tsx | 9 +++++++++ 11 files changed, 56 insertions(+), 6 deletions(-) diff --git a/api/src/auth/RoomAuth.ts b/api/src/auth/RoomAuth.ts index 2cafc674..16f1eb35 100644 --- a/api/src/auth/RoomAuth.ts +++ b/api/src/auth/RoomAuth.ts @@ -76,6 +76,8 @@ export const hasPermission = ( case 'changeColor': return !payload.isSpectating; case 'startTimer': + case 'changeRaceHandler': + case 'resetTimer': return payload.isMonitor; default: return true; diff --git a/api/src/core/Room.ts b/api/src/core/Room.ts index 95cf47df..64e311fa 100644 --- a/api/src/core/Room.ts +++ b/api/src/core/Room.ts @@ -619,6 +619,11 @@ export default class Room { this.sendRoomData(); } + handleResetTimer() { + this.raceHandler?.resetTimer(); + this.sendRoomData(); + } + handleSocketClose(ws: WebSocket) { let player: Player | undefined; for (const p of this.players.values()) { diff --git a/api/src/core/RoomServer.ts b/api/src/core/RoomServer.ts index 9aef6918..c98b57d7 100644 --- a/api/src/core/RoomServer.ts +++ b/api/src/core/RoomServer.ts @@ -157,6 +157,9 @@ roomWebSocketServer.on('connection', (ws, req) => { 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 index 1857dca1..45c32445 100644 --- a/api/src/core/integration/races/LocalTimer.ts +++ b/api/src/core/integration/races/LocalTimer.ts @@ -55,6 +55,14 @@ export default class LocalTimer implements RaceHandler { return this.finishedAt; } + resetTimer(): void { + this.startedAt = undefined; + this.finishedAt = undefined; + this.room.players.forEach((player) => { + player.finishedAt = undefined; + }); + } + startTimer(): void { this.startedAt = new Date().toISOString(); this.room.revealCardForAllPlayers(); diff --git a/api/src/core/integration/races/RaceHandler.ts b/api/src/core/integration/races/RaceHandler.ts index fdf8c603..268c5ab0 100644 --- a/api/src/core/integration/races/RaceHandler.ts +++ b/api/src/core/integration/races/RaceHandler.ts @@ -89,6 +89,11 @@ export default interface RaceHandler { */ startTimer(): void; + /** + * Resets the race timer + */ + resetTimer(): void; + /** * Signals to the race platform that a player has completed the bingo goal * diff --git a/api/src/core/integration/races/RacetimeHandler.ts b/api/src/core/integration/races/RacetimeHandler.ts index 79f004ce..326e0dbe 100644 --- a/api/src/core/integration/races/RacetimeHandler.ts +++ b/api/src/core/integration/races/RacetimeHandler.ts @@ -434,9 +434,9 @@ export default class RacetimeHandler implements RaceHandler { return this.data?.ended_at ?? undefined; } - startTimer(): void { - throw new Error('Method not implemented.'); - } + // racetime player websocket doesn't support these actions + resetTimer(): void {} + startTimer(): void {} async playerFinished(player: Player): Promise { if (!this.connected || !this.websocketConnected || !this.socket) { diff --git a/schema/schemas/RoomAction.json b/schema/schemas/RoomAction.json index c9f26821..7cb8d2ac 100644 --- a/schema/schemas/RoomAction.json +++ b/schema/schemas/RoomAction.json @@ -18,7 +18,8 @@ {"$ref": "#/$defs/RevealCardAction"}, {"$ref": "#/$defs/ChangeAuthAction"}, {"$ref": "#/$defs/StartTimerAction"}, - {"$ref": "#/$defs/ChangeRaceHandlerAction"} + {"$ref": "#/$defs/ChangeRaceHandlerAction"}, + {"$ref": "#/$defs/ResetTimerAction"} ], "$defs": { @@ -152,6 +153,13 @@ "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/types/RoomAction.d.ts b/schema/types/RoomAction.d.ts index c24e0bf8..da9a7a1c 100644 --- a/schema/types/RoomAction.d.ts +++ b/schema/types/RoomAction.d.ts @@ -20,6 +20,7 @@ export type RoomAction = ( | ChangeAuthAction | StartTimerAction | ChangeRaceHandlerAction + | ResetTimerAction ) & { /** * JWT for the room obtained from the server @@ -86,3 +87,6 @@ export interface ChangeRaceHandlerAction { action: "changeRaceHandler"; raceHandler: "racetime" | "local"; } +export interface ResetTimerAction { + action: "resetTimer"; +} diff --git a/web/src/components/room/timer/Timer.tsx b/web/src/components/room/timer/Timer.tsx index 0ff873aa..ac401239 100644 --- a/web/src/components/room/timer/Timer.tsx +++ b/web/src/components/room/timer/Timer.tsx @@ -7,7 +7,8 @@ import RacetimeCard from '../racetime/RacetimeCard'; import TimerDisplay from './TimerDisplay'; export default function Timer() { - const { roomData, startTimer } = useContext(RoomContext); + const { roomData, startTimer, resetTimer, connectedPlayer } = + useContext(RoomContext); if (!roomData) { return null; @@ -33,7 +34,10 @@ export default function Timer() { color="inherit" sx={{ mt: 0.5 }} > - {started ? ( diff --git a/web/src/components/room/timer/TimerDisplay.tsx b/web/src/components/room/timer/TimerDisplay.tsx index b56fbe70..9b3ed946 100644 --- a/web/src/components/room/timer/TimerDisplay.tsx +++ b/web/src/components/room/timer/TimerDisplay.tsx @@ -41,6 +41,8 @@ export default function TimerDisplay({ offset }: { offset: Duration }) { setDur(end.diff(start)); } else if (start) { setDur(DateTime.now().diff(start).normalize()); + } else { + setDur(offset); } }, interval); diff --git a/web/src/context/RoomContext.tsx b/web/src/context/RoomContext.tsx index c7b5324e..a2e39219 100644 --- a/web/src/context/RoomContext.tsx +++ b/web/src/context/RoomContext.tsx @@ -84,6 +84,7 @@ interface RoomContext { changeAuth: (spectate: boolean) => void; startTimer: () => void; changeRaceHandler: (handler: string) => void; + resetTimer: () => void; } export const RoomContext = createContext({ @@ -119,6 +120,7 @@ export const RoomContext = createContext({ changeAuth() {}, startTimer() {}, changeRaceHandler() {}, + resetTimer() {}, }); interface RoomContextProps { @@ -539,6 +541,12 @@ export function RoomContextProvider({ }, [authToken, sendJsonMessage], ); + const resetTimer = useCallback(() => { + sendJsonMessage({ + action: 'resetTimer', + authToken, + } as RoomAction); + }, [authToken, sendJsonMessage]); return ( {children} From e3d5011ace95b631436abd732b937b175622d36a Mon Sep 17 00:00:00 2001 From: cjs8487 Date: Sat, 20 Dec 2025 18:22:48 -0500 Subject: [PATCH 13/16] serialize local timing data to database --- .../migration.sql | 10 +++++++ api/prisma/schema.prisma | 29 +++++++++++------- api/src/core/Room.ts | 22 +++++++++----- api/src/core/integration/races/LocalTimer.ts | 21 +++++++++++-- api/src/core/integration/races/RaceHandler.ts | 3 +- .../core/integration/races/RacetimeHandler.ts | 5 ++-- api/src/database/Rooms.ts | 30 ++++++++++++++++++- api/src/routes/rooms/Rooms.ts | 20 ++++++++++++- schema/schemas/RoomData.json | 2 +- schema/types/RoomData.d.ts | 2 +- schema/types/ServerMessage.d.ts | 2 +- web/src/components/room/PlayerRaceSummary.tsx | 4 +-- web/src/components/room/RoomControlDialog.tsx | 4 +-- 13 files changed, 121 insertions(+), 33 deletions(-) create mode 100644 api/prisma/migrations/20251220170125_add_local_timer_fields/migration.sql 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/core/Room.ts b/api/src/core/Room.ts index 64e311fa..cbe1af97 100644 --- a/api/src/core/Room.ts +++ b/api/src/core/Room.ts @@ -31,6 +31,7 @@ import { addUnmarkAction, createUpdatePlayer, setRoomBoard, + updateRaceHandler, } from '../database/Rooms'; import { isStaff } from '../database/Users'; import { @@ -433,14 +434,18 @@ export default class Room { name: this.name, gameSlug: this.gameSlug, newGenerator: this.newGenerator, - racetimeConnection: { - 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, - }, + 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(), @@ -617,6 +622,7 @@ export default class Room { break; } this.sendRoomData(); + updateRaceHandler(this.id, this.raceHandler.key()).then(); } handleResetTimer() { diff --git a/api/src/core/integration/races/LocalTimer.ts b/api/src/core/integration/races/LocalTimer.ts index 45c32445..6f1fe825 100644 --- a/api/src/core/integration/races/LocalTimer.ts +++ b/api/src/core/integration/races/LocalTimer.ts @@ -2,6 +2,12 @@ import { RaceStatusConnected } from '@playbingo/types'; import RaceHandler from './RaceHandler'; import Player from '../../Player'; import Room from '../../Room'; +import { + createUpdatePlayer, + updateFinishTime, + updateStartTime, +} from '../../../database/Rooms'; +import { RaceHandler as RaceHandlers } from '@prisma/client'; export default class LocalTimer implements RaceHandler { startedAt?: string; @@ -12,8 +18,8 @@ export default class LocalTimer implements RaceHandler { this.room = room; } - key(): 'local' | 'racetime' { - return 'local'; + key() { + return RaceHandlers.LOCAL; } connect(url: string): void {} @@ -58,29 +64,38 @@ export default class LocalTimer implements RaceHandler { 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 { this.startedAt = new Date().toISOString(); this.room.revealCardForAllPlayers(); + updateStartTime(this.room.id, new Date()).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 { - this.finishedAt = new Date().toISOString(); + 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 268c5ab0..66b3003b 100644 --- a/api/src/core/integration/races/RaceHandler.ts +++ b/api/src/core/integration/races/RaceHandler.ts @@ -1,5 +1,6 @@ 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. @@ -26,7 +27,7 @@ export default interface RaceHandler { /** * Returns a unique key for the race handler */ - key(): 'local' | 'racetime'; + key(): RaceHandlers; /** * Connects the bingo room to the race room * diff --git a/api/src/core/integration/races/RacetimeHandler.ts b/api/src/core/integration/races/RacetimeHandler.ts index 326e0dbe..ba83e2e6 100644 --- a/api/src/core/integration/races/RacetimeHandler.ts +++ b/api/src/core/integration/races/RacetimeHandler.ts @@ -6,6 +6,7 @@ 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; @@ -119,8 +120,8 @@ export default class RacetimeHandler implements RaceHandler { this.room = room; } - key(): 'local' | 'racetime' { - return 'racetime'; + key() { + return RaceHandlers.RACETIME; } //#region Synchronous Websocket Functions 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 4888e366..b9eab969 100644 --- a/api/src/routes/rooms/Rooms.ts +++ b/api/src/routes/rooms/Rooms.ts @@ -26,9 +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(); @@ -336,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); }); @@ -402,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; diff --git a/schema/schemas/RoomData.json b/schema/schemas/RoomData.json index 910ce70e..48f0bf7e 100644 --- a/schema/schemas/RoomData.json +++ b/schema/schemas/RoomData.json @@ -48,7 +48,7 @@ "type": "string" }, "raceHandler": { - "enum": ["local", "racetime"] + "enum": ["LOCAL", "RACETIME"] } }, "$defs": { diff --git a/schema/types/RoomData.d.ts b/schema/types/RoomData.d.ts index 3891f837..65503643 100644 --- a/schema/types/RoomData.d.ts +++ b/schema/types/RoomData.d.ts @@ -23,7 +23,7 @@ export interface RoomData { mode: string; startedAt?: string; finishedAt?: string; - raceHandler?: "local" | "racetime"; + raceHandler?: "LOCAL" | "RACETIME"; } export interface RacetimeConnection { /** diff --git a/schema/types/ServerMessage.d.ts b/schema/types/ServerMessage.d.ts index 762671a2..b5dfabf0 100644 --- a/schema/types/ServerMessage.d.ts +++ b/schema/types/ServerMessage.d.ts @@ -126,7 +126,7 @@ export interface RoomData { mode: string; startedAt?: string; finishedAt?: string; - raceHandler?: "local" | "racetime"; + raceHandler?: "LOCAL" | "RACETIME"; } export interface RacetimeConnection { /** diff --git a/web/src/components/room/PlayerRaceSummary.tsx b/web/src/components/room/PlayerRaceSummary.tsx index 21796d14..391605ae 100644 --- a/web/src/components/room/PlayerRaceSummary.tsx +++ b/web/src/components/room/PlayerRaceSummary.tsx @@ -14,9 +14,9 @@ export default function PlayerRaceSummary({ raceHandler, player }: Props) { } switch (raceHandler) { - case 'racetime': + case 'RACETIME': return ; - case 'local': + case 'LOCAL': return ; } } diff --git a/web/src/components/room/RoomControlDialog.tsx b/web/src/components/room/RoomControlDialog.tsx index c2730931..d8e8dd86 100644 --- a/web/src/components/room/RoomControlDialog.tsx +++ b/web/src/components/room/RoomControlDialog.tsx @@ -83,7 +83,7 @@ export default function RoomControlDialog({ size="small" > @@ -94,7 +94,7 @@ export default function RoomControlDialog({ Date: Thu, 5 Feb 2026 19:20:14 -0600 Subject: [PATCH 14/16] Use same date object when starting timer --- api/src/core/integration/races/LocalTimer.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/api/src/core/integration/races/LocalTimer.ts b/api/src/core/integration/races/LocalTimer.ts index 6f1fe825..e5061d20 100644 --- a/api/src/core/integration/races/LocalTimer.ts +++ b/api/src/core/integration/races/LocalTimer.ts @@ -1,13 +1,13 @@ import { RaceStatusConnected } from '@playbingo/types'; -import RaceHandler from './RaceHandler'; -import Player from '../../Player'; -import Room from '../../Room'; +import { RaceHandler as RaceHandlers } from '@prisma/client'; import { createUpdatePlayer, updateFinishTime, updateStartTime, } from '../../../database/Rooms'; -import { RaceHandler as RaceHandlers } from '@prisma/client'; +import Player from '../../Player'; +import Room from '../../Room'; +import RaceHandler from './RaceHandler'; export default class LocalTimer implements RaceHandler { startedAt?: string; @@ -73,9 +73,10 @@ export default class LocalTimer implements RaceHandler { } startTimer(): void { - this.startedAt = new Date().toISOString(); + const now = new Date(); + this.startedAt = now.toISOString(); this.room.revealCardForAllPlayers(); - updateStartTime(this.room.id, new Date()).then(); + updateStartTime(this.room.id, now).then(); } async playerFinished(player: Player): Promise { From ec703a43fec2c1276f4a64ae63fc83edaaa8cfd9 Mon Sep 17 00:00:00 2001 From: cjs8487 Date: Thu, 5 Feb 2026 19:21:05 -0600 Subject: [PATCH 15/16] remove rt debug logging --- api/src/core/integration/races/RacetimeHandler.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/api/src/core/integration/races/RacetimeHandler.ts b/api/src/core/integration/races/RacetimeHandler.ts index ba83e2e6..ed8f3524 100644 --- a/api/src/core/integration/races/RacetimeHandler.ts +++ b/api/src/core/integration/races/RacetimeHandler.ts @@ -255,7 +255,6 @@ export default class RacetimeHandler implements RaceHandler { handleWebsocketMessage(data: RawData) { const message: WebSocketMessage = JSON.parse(data.toString()); - this.room.logDebug(`racetime.gg ws message: ${message.type}`); switch (message.type) { case 'pong': if (this.nextPongCallback) { From 2bf51b0ab01cc74d07648fd3a5f321f2f09d3900 Mon Sep 17 00:00:00 2001 From: cjs8487 Date: Thu, 5 Feb 2026 19:37:53 -0600 Subject: [PATCH 16/16] replace racetime timer in all size layouts --- web/src/app/(main)/rooms/[slug]/page.tsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/web/src/app/(main)/rooms/[slug]/page.tsx b/web/src/app/(main)/rooms/[slug]/page.tsx index 86db28e4..9b6b2333 100644 --- a/web/src/app/(main)/rooms/[slug]/page.tsx +++ b/web/src/app/(main)/rooms/[slug]/page.tsx @@ -2,7 +2,6 @@ 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'; @@ -117,7 +116,7 @@ function RoomXs() { return ( - + - + @@ -195,7 +194,7 @@ function RoomMd() { - +