diff --git a/apps/client/src/components/molecules/avatar-array/AvatarArray.component.tsx b/apps/client/src/components/molecules/avatar-array/AvatarArray.component.tsx index ac06288b..d185216a 100644 --- a/apps/client/src/components/molecules/avatar-array/AvatarArray.component.tsx +++ b/apps/client/src/components/molecules/avatar-array/AvatarArray.component.tsx @@ -23,7 +23,7 @@ export function AvatarArray({ return (
@@ -35,7 +35,7 @@ export function AvatarArray({ key={reversedIndex} id={`avatar-${reversedIndex}`} className={cs( - 'w-8 h-8 rounded-full border-2 border-neutral-20', + 'w-7 h-7 rounded-full border-2 border-neutral-20', 'flex items-center justify-center', { '-ml-2': index !== 0, diff --git a/apps/client/src/components/molecules/timer/Timer.component.tsx b/apps/client/src/components/molecules/timer/Timer.component.tsx index 6c0f5918..b49c98c8 100644 --- a/apps/client/src/components/molecules/timer/Timer.component.tsx +++ b/apps/client/src/components/molecules/timer/Timer.component.tsx @@ -6,7 +6,7 @@ import { Text } from '../../atoms'; export interface TimerProps { time: number; // in seconds - onTimeEnd: () => void; + onTimeEnd?: () => void; } /** @@ -27,6 +27,25 @@ export function Timer({ const timerEnded = useRef(false); useEffect(() => { + startTimestamp.current = Date.now(); + previousTimestamp.current = Date.now(); + setSeconds(time); + setMilliseconds(0); + timerEnded.current = false; + }, [time]); + + useEffect(() => { + const circle = circleIndicator.current; + let circumference: number; + + if (circle) { + const radius = circle.r.baseVal.value; + // C=2πr + circumference = 2 * Math.PI * radius; + // Fill circle dash + circle.setAttribute('stroke-dasharray', `${circumference}, 20000`); + } + const updateTimer = (): void => { if (timerEnded.current) { return; @@ -42,39 +61,33 @@ export function Timer({ previousTimestamp.current = now; const delta = now - startTimestamp.current; - const remainingSeconds = Math.max(0, time - Math.floor(delta / 1000)); - const remainingMilliseconds = Math.max( - 0, - 99 - Math.floor((delta % 1000) / 10), + const remainingTime = time * 1000 - delta; + const remainingSeconds = Math.max(0, Math.floor(remainingTime / 1000)); + const remainingMilliseconds = Math.floor( + (remainingTime - remainingSeconds * 1000) / 10, ); setSeconds(remainingSeconds); setMilliseconds(remainingMilliseconds); - const circle = circleIndicator.current; - if (circle) { - const radius = circle.r.baseVal.value; - // C=2πr - const circumference = 2 * Math.PI * radius; - - let dashOffset = (delta / 1000 / time) * circumference; - dashOffset = Math.min(circumference, Math.max(0, dashOffset)); + // Dash offset = Fraction of time elapsed * circumference + let dashOffset = (remainingTime / (time * 1000)) * circumference; + // Clamp dash offset to the circumference + dashOffset = Math.min(circumference, dashOffset); - circle.setAttribute('stroke-dasharray', `${circumference}`); circle.setAttribute( 'stroke-dashoffset', `${circumference - dashOffset}`, ); + } - // When time is up setting seconds and milliseconds to 0 - if (time <= delta / 1000) { - clearInterval(interval); - timerEnded.current = true; - console.log(Date.now() - startTimestamp.current); - setSeconds(0); - setMilliseconds(0); - onTimeEnd(); - } + // When time is up setting seconds and milliseconds to 0 + if (remainingTime <= 0) { + clearInterval(interval); + timerEnded.current = true; + setSeconds(0); + setMilliseconds(0); + onTimeEnd(); } requestAnimationFrame(updateTimer); @@ -87,6 +100,7 @@ export function Timer({ return (
{ - const { id: playerId, state, ...playerData } = data.player; +type PlayerJoinControllerArgs = + AllClientPubSubEventsToTypeMap[SocketProtocols.PlayerJoin]; - // converted PlayerState to AppPlayerState, - // because we're using two models for store and socket - const appPlayerState = playerStateToAppPlayerState(state); +function playerJoinController({ + data, + tournamentId, +}: PlayerJoinControllerArgs): void { + const { id: playerId, state, ...playerData } = data.player; - const dataToDispatch: AddPlayerPayload = { - playerId, - player: { - ...playerData, - state: appPlayerState, - // This is equal because received player and joined player on same tournament - tournamentId, - }, - }; + // converted PlayerState to AppPlayerState, + // because we're using two models for store and socket + const appPlayerState = playerStateToAppPlayerState(state); - store.dispatch.game.addPlayer({ ...dataToDispatch }); - }, -); + const dataToDispatch: AddPlayerPayload = { + playerId, + player: { + ...playerData, + state: appPlayerState, + // This is equal because received player and joined player on same tournament + tournamentId, + }, + }; + + store.dispatch.game.addPlayer({ ...dataToDispatch }); +} + +pubsub.subscribe(SocketProtocols.PlayerJoin, playerJoinController); diff --git a/apps/client/src/controllers/start-race.controller.ts b/apps/client/src/controllers/start-race.controller.ts new file mode 100644 index 00000000..1611377e --- /dev/null +++ b/apps/client/src/controllers/start-race.controller.ts @@ -0,0 +1,23 @@ +import { SocketProtocols } from '@razor/models'; +import { store } from '@razor/store'; + +import { AllClientPubSubEventsToTypeMap } from '../models'; +import { pubsub } from '../utils/pubsub'; + +type StartRaceControllerArgs = + AllClientPubSubEventsToTypeMap[SocketProtocols.StartRaceAccept]; + +function StartRaceController({ + data, + tournamentId, +}: StartRaceControllerArgs): void { + const { raceStartedBy, raceText, ..._ } = data; + + store.dispatch.game.startCountdown({ + tournamentId, + playerId: raceStartedBy, + raceText, + }); +} + +pubsub.subscribe(SocketProtocols.StartRaceAccept, StartRaceController); diff --git a/apps/client/src/controllers/update-type-logs.controller.ts b/apps/client/src/controllers/update-type-logs.controller.ts new file mode 100644 index 00000000..8272fb2b --- /dev/null +++ b/apps/client/src/controllers/update-type-logs.controller.ts @@ -0,0 +1,32 @@ +import { PlayerId, SocketProtocols } from '@razor/models'; +import { store } from '@razor/store'; + +import { AllClientPubSubEventsToTypeMap } from '../models'; +import { pubsub } from '../utils/pubsub'; + +type UpdateTypeLogsControllerArgs = + AllClientPubSubEventsToTypeMap[SocketProtocols.UpdateTypeLogs]; + +function updateTypeLogsController({ + data, + savedPlayerId, +}: UpdateTypeLogsControllerArgs): void { + const { raceId, playerLogs: playersLogs } = data; + const race = store.getState().game.racesModel[raceId]; + const racePlayerIds = Object.keys(race.players) as PlayerId[]; + + for (const playerId of racePlayerIds) { + const logs = playersLogs[playerId]; + if (!logs || playerId === savedPlayerId) { + continue; + } + + store.dispatch.game.sendTypeLog({ + playerLog: logs, + playerId, + raceId, + }); + } +} + +pubsub.subscribe(SocketProtocols.UpdateTypeLogs, updateTypeLogsController); diff --git a/apps/client/src/i18n/en/race.json b/apps/client/src/i18n/en/race.json index 13557959..33460173 100644 --- a/apps/client/src/i18n/en/race.json +++ b/apps/client/src/i18n/en/race.json @@ -1,3 +1,9 @@ { - "page": "Race" + "page": "Race", + "toasts": { + "race_start": { + "title": "Ready, set, go!", + "message": "{{player_name}} started the race. Good luck!" + } + } } diff --git a/apps/client/src/models/pubsub/pubsub-events.ts b/apps/client/src/models/pubsub/pubsub-events.ts index f706ad82..3d549e6f 100644 --- a/apps/client/src/models/pubsub/pubsub-events.ts +++ b/apps/client/src/models/pubsub/pubsub-events.ts @@ -33,6 +33,6 @@ type ModifiedOtherProtocolToTypeMap = { >; }; -export type AllServerPubSubEventsToTypeMap = ClientUniqueEventsToTypeMap & +export type AllClientPubSubEventsToTypeMap = ClientUniqueEventsToTypeMap & InitialProtocolToTypeMap & ModifiedOtherProtocolToTypeMap; diff --git a/apps/client/src/pages/home/Home.page.tsx b/apps/client/src/pages/home/Home.page.tsx index 778f0957..147b48cb 100644 --- a/apps/client/src/pages/home/Home.page.tsx +++ b/apps/client/src/pages/home/Home.page.tsx @@ -21,11 +21,11 @@ import { Text, } from '../../components'; import { TextSize, TextType } from '../../models'; +import { endSocket } from '../../services'; import { - endSocket, requestToCreateRoom, requestToJoinRoom, -} from '../../services'; +} from '../../services/handlers'; export function Home(): ReactElement { const { t } = useTranslation('home'); diff --git a/apps/client/src/pages/race/Race.page.tsx b/apps/client/src/pages/race/Race.page.tsx index ee266c2e..a6e60b7d 100644 --- a/apps/client/src/pages/race/Race.page.tsx +++ b/apps/client/src/pages/race/Race.page.tsx @@ -1,21 +1,36 @@ -import { ReactElement, useEffect, useState } from 'react'; +import { ReactElement, useEffect, useRef, useState } from 'react'; +import { Trans, useTranslation } from 'react-i18next'; import { useSelector } from 'react-redux'; -import { useNavigate, useParams } from 'react-router-dom'; -import { AppRaceId, AppTournamentId } from '@razor/models'; +import { useParams } from 'react-router-dom'; +import { AppRaceId, AppTournamentId, PlayerId } from '@razor/models'; import { RootState } from '@razor/store'; +import cs from 'classnames'; +import { ReactComponent as CarIcon } from 'pixelarticons/svg/car.svg'; -import { Text } from '../../components'; +import { ReactComponent as Logo } from '../../assets/images/logo.svg'; +import { Text, ToastType } from '../../components'; +import { Timer } from '../../components/molecules/timer'; +import { useToastContext } from '../../hooks/useToastContext'; import { TextSize, TextType } from '../../models'; +import { + sendInitialTypeLog, + sendTypeLog, + typeLogPusher, +} from '../../services/handlers/send-type-log'; +import { getSavedPlayerId } from '../../utils/save-player-id'; +import { RaceText } from './templates/race-text/RaceText.template'; import { RaceTrack } from './templates/race-view/RaceTrack.template'; export function Race(): ReactElement { const { roomId } = useParams(); - - const navigate = useNavigate(); - + const { t } = useTranslation(['race']); + const addToast = useToastContext(); const game = useSelector((store: RootState) => store.game); const [raceId, setRaceId] = useState(null); + const [raceReadyTime, setRaceReadyTime] = useState(5); + const [raceTime, setRaceTime] = useState(0); + const selfPlayerId = useRef(getSavedPlayerId()); useEffect(() => { const tournamentId: AppTournamentId = `T:${roomId}`; @@ -30,23 +45,119 @@ export function Race(): ReactElement { setRaceId(null); } + // Race ready timer + const timer = setInterval(() => { + setRaceReadyTime(prev => { + if (prev <= 1) { + clearInterval(timer); + prev = 0; + } + return prev - 1; + }); + }, 1000); + + let gameStartedPlayerId: PlayerId | null = null; + + // Type log pusher + // eslint-disable-next-line @typescript-eslint/no-empty-function + let clearPusher = (): void => {}; + + if (raceId) { + clearPusher = typeLogPusher(raceId); + gameStartedPlayerId = game.racesModel[raceId]?.raceStartedBy; + } + + // Get player who started the race + if (gameStartedPlayerId) { + const gameStartedPlayerName = + selfPlayerId.current !== gameStartedPlayerId + ? game.playersModel[gameStartedPlayerId]?.name + : 'You'; + + if (gameStartedPlayerName) { + addToast({ + title: t('toasts.race_start.title'), + type: ToastType.Info, + message: ( + + {{ player_name: gameStartedPlayerName }} has started the race. + Good luck! + + ) as unknown as string, + icon: , + }); + } + } + return () => { - setRaceId(null); + clearInterval(timer); + clearPusher(); }; - }, [game, roomId]); + }, []); + + useEffect((): void => { + if (raceId && raceReadyTime <= 0) { + const raceTime = game.racesModel[raceId]?.timeoutDuration; + setRaceTime(raceTime); + sendInitialTypeLog(raceId); + } + }, [raceReadyTime, raceId]); return ( -
- - Race - - {raceId ? : null} - +
+ + {raceId ? ( +
+ {raceReadyTime > 0 ? ( +
+ + {raceReadyTime.toString()} + +
+ ) : null} +
0, + })}> +
+ +
+
+
+ console.log('time end')} + /> +
+
+ + sendTypeLog(charIndex + 1, raceId) + } + /> +
+
+
+
+ ) : ( +

Race not found

+ )}
); } diff --git a/apps/client/src/pages/race/story-common-utils/sample-race-logs.ts b/apps/client/src/pages/race/story-common-utils/sample-race-logs.ts index 37949c2d..7084d61c 100644 --- a/apps/client/src/pages/race/story-common-utils/sample-race-logs.ts +++ b/apps/client/src/pages/race/story-common-utils/sample-race-logs.ts @@ -1,6 +1,5 @@ import { addPlayer, - raceId, store, testTournamentId, updatePlayerLog, @@ -30,6 +29,4 @@ export function addSampleRaceLogs(): void { } updatePlayerLog(playerId, positions[index - 1]); }); - - console.log('players', game.racesModel[raceId].players); } diff --git a/apps/client/src/pages/race/story-common-utils/test-race.ts b/apps/client/src/pages/race/story-common-utils/test-race.ts index a5fa3b5f..7733cce0 100644 --- a/apps/client/src/pages/race/story-common-utils/test-race.ts +++ b/apps/client/src/pages/race/story-common-utils/test-race.ts @@ -26,7 +26,7 @@ const playersModel = game.playersModel; const players = Object.keys(playersModel); const player1Id = players[0] as AppPlayerId; export const testTournamentId = playersModel[player1Id].tournamentId; -export const raceId: AppRaceId = `${testTournamentId}-R:000`; +export const testRaceId: AppRaceId = `${testTournamentId}-R:000`; store.dispatch.game.joinPlayer({ receivedTournamentId: testTournamentId, @@ -44,7 +44,7 @@ const playerIds = game.tournamentsModel[testTournamentId].playerIds; playerIds.forEach(playerId => { store.dispatch.game.sendTypeLog({ - raceId: raceId, + raceId: testRaceId, playerId: playerId, playerLog: { timestamp: Date.now(), @@ -65,7 +65,7 @@ export const addPlayer = (count: number): void => { const playerId = playerIds[playerIds.length - 1]; store.dispatch.game.sendTypeLog({ - raceId: raceId, + raceId: testRaceId, playerId: playerId, playerLog: { timestamp: Date.now(), @@ -76,7 +76,7 @@ export const addPlayer = (count: number): void => { // Adding player to the race manually. // Store is not designed to add players after race started. // But for testing purpose, we are directly updating the race. - const race = game.racesModel[raceId]; + const race = game.racesModel[testRaceId]; const playersModel = game.playersModel; const { [playerId]: newPlayer, ..._others } = playersModel; const updatedRace: AppRace = { @@ -90,7 +90,7 @@ export const addPlayer = (count: number): void => { }, }; store.dispatch.game.updateRaceReducer({ - raceId: raceId, + raceId: testRaceId, race: updatedRace, }); }; @@ -109,7 +109,7 @@ export const clearLastPlayer = (): void => { playerId: playerIds[playerIds.length - 1], }); // Removing last player from the race manually. - const race = game.racesModel[raceId]; + const race = game.racesModel[testRaceId]; const racePlayers = race.players; // Remove player with last index const lastPlayerId: AppPlayerId = Object.keys(racePlayers)[ @@ -121,7 +121,7 @@ export const clearLastPlayer = (): void => { players: playersBeforeLast, }; store.dispatch.game.updateRaceReducer({ - raceId: raceId, + raceId: testRaceId, race: updatedRace, }); }; @@ -131,10 +131,10 @@ export const updatePlayerLog = (playerId: AppPlayerId, value: number): void => { return; } game = store.getState().game; - const playerLog = game.playerLogsModel[`${raceId}-${playerId}`]; + const playerLog = game.playerLogsModel[`${testRaceId}-${playerId}`]; const lastPlayerLog = playerLog[playerLog.length - 1]; store.dispatch.game.sendTypeLog({ - raceId, + raceId: testRaceId, playerId, playerLog: { timestamp: Date.now(), diff --git a/apps/client/src/pages/race/templates/race-text/RaceText.stories.tsx b/apps/client/src/pages/race/templates/race-text/RaceText.stories.tsx index b758fc6b..23ff2427 100644 --- a/apps/client/src/pages/race/templates/race-text/RaceText.stories.tsx +++ b/apps/client/src/pages/race/templates/race-text/RaceText.stories.tsx @@ -2,27 +2,45 @@ import { ReactElement } from 'react'; import { Provider } from 'react-redux'; import { Meta } from '@storybook/react'; import { ToastContextProvider } from 'apps/client/src/providers'; +import { getSavedPlayerId } from 'apps/client/src/utils/save-player-id'; import { addSampleRaceLogs, RaceLogUpdaters, store, - testTournamentId, + testRaceId, } from '../../story-common-utils'; import { RaceText, RaceTextProps } from './RaceText.template'; +function sendTypeLog(playerCursorAt: number): void { + const playerLog = { + timestamp: Date.now(), + textLength: playerCursorAt + 1, + }; + + const playerId = getSavedPlayerId(); + if (playerId) { + store.dispatch.game.sendTypeLog({ + playerLog, + raceId: testRaceId, + playerId, + }); + } +} + export default { title: 'Templates/RaceText', component: RaceText, args: { - raceId: `${testTournamentId}-R:000`, + raceId: testRaceId, debug: { enableLetterCount: false, enableSpaceCount: false, highlightRightMostWords: false, highlightLeftMostWords: false, }, + onValidType: sendTypeLog, }, } as Meta; diff --git a/apps/client/src/pages/race/templates/race-text/RaceText.template.tsx b/apps/client/src/pages/race/templates/race-text/RaceText.template.tsx index 15cac0f1..4ab7f9fe 100644 --- a/apps/client/src/pages/race/templates/race-text/RaceText.template.tsx +++ b/apps/client/src/pages/race/templates/race-text/RaceText.template.tsx @@ -7,7 +7,7 @@ import { useState, } from 'react'; import { useTranslation } from 'react-i18next'; -import { useDispatch, useSelector } from 'react-redux'; +import { useSelector } from 'react-redux'; import { AppPlayerId, AppPlayerLogId, @@ -15,7 +15,7 @@ import { AppRaceId, AppRacePlayerCursor, } from '@razor/models'; -import { Dispatch, RootState } from '@razor/store'; +import { RootState } from '@razor/store'; import { Cursor, ToastType, UnderlineCursor } from 'apps/client/src/components'; import { AvatarArray } from 'apps/client/src/components/molecules/avatar-array/AvatarArray.component'; import { MAX_INVALID_CHARS_ALLOWED } from 'apps/client/src/constants/race'; @@ -35,6 +35,7 @@ import { export interface RaceTextProps { raceId: AppRaceId; + onValidType: (charIndex: number) => void; debug?: { enableLetterCount?: boolean; enableSpaceCount?: boolean; @@ -43,9 +44,13 @@ export interface RaceTextProps { }; } -export function RaceText({ raceId, debug = {} }: RaceTextProps): ReactElement { +export function RaceText({ + raceId, + // eslint-disable-next-line @typescript-eslint/no-empty-function + onValidType = (): void => {}, + debug = {}, +}: RaceTextProps): ReactElement { const game = useSelector((store: RootState) => store.game); - const dispatch = useDispatch(); const { t } = useTranslation(['race', 'common']); const addToast = useToastContext(); @@ -194,16 +199,9 @@ export function RaceText({ raceId, debug = {} }: RaceTextProps): ReactElement { if (noOfInvalidChars > 0) { return; } - const playerLog = { - timestamp: Date.now(), - textLength: playerCursorAt + 1, - }; + updatePlayerCursorAt(playerCursorAt + 1); - dispatch.game.sendTypeLog({ - playerLog, - raceId, - playerId: selfPlayerId.current, - }); + onValidType(playerCursorAt); } else if (inputStatus === InputStatus.INCORRECT) { updateNoOfInvalidChars(prev => { if (prev === MAX_INVALID_CHARS_ALLOWED) { @@ -229,7 +227,7 @@ export function RaceText({ raceId, debug = {} }: RaceTextProps): ReactElement { return (
diff --git a/apps/client/src/pages/race/templates/race-text/utils/get-cursor-position.ts b/apps/client/src/pages/race/templates/race-text/utils/get-cursor-position.ts index 47d40e85..2d70b415 100644 --- a/apps/client/src/pages/race/templates/race-text/utils/get-cursor-position.ts +++ b/apps/client/src/pages/race/templates/race-text/utils/get-cursor-position.ts @@ -10,6 +10,9 @@ import { extractId, ExtractIdType } from '@razor/util'; /** Get cursor position base on player logs. */ export function getCursorPosition(playerLogs: AppPlayerLog[]): number { + if (!playerLogs?.length) { + return 0; + } return playerLogs[playerLogs.length - 1].textLength; } diff --git a/apps/client/src/pages/race/templates/race-view/RaceTrack.template.tsx b/apps/client/src/pages/race/templates/race-view/RaceTrack.template.tsx index 9955fb5b..a97af843 100644 --- a/apps/client/src/pages/race/templates/race-view/RaceTrack.template.tsx +++ b/apps/client/src/pages/race/templates/race-view/RaceTrack.template.tsx @@ -15,9 +15,10 @@ import { RaceLine } from './race-line'; export interface RaceTrackProps { raceId: AppRaceId; + className?: string; } -export function RaceTrack({ raceId }: RaceTrackProps): ReactElement { +export function RaceTrack({ raceId, className }: RaceTrackProps): ReactElement { const game = useSelector((store: RootState) => store.game); const tournamentId = extractId( raceId, @@ -34,11 +35,11 @@ export function RaceTrack({ raceId }: RaceTrackProps): ReactElement { getRaceTrackPavementRows(playerIds.length) * lineHeight; return ( -
+
store.game); const tournamentId: AppTournamentId = `T:${roomId}`; + const [playerIds, setPlayerIds] = useState( + game.tournamentsModel[tournamentId]?.playerIds, + ); const [hostname, setHostname] = useState(''); const addToast = useToastContext(); @@ -35,6 +42,22 @@ export function Room(): ReactElement { }; }, []); + // If a race ongoing on store navigate to the race page. + useEffect((): void => { + const raceIds = game.tournamentsModel[tournamentId]?.raceIds || []; + const lastRaceId = raceIds[raceIds?.length - 1] || null; + const race = lastRaceId ? game.racesModel[lastRaceId] : null; + + if (race?.isOnGoing) { + navigate(`/${roomId}/race`); + } + }, [game, roomId]); + + // Update playerIds + useEffect(() => { + setPlayerIds(game.tournamentsModel[tournamentId]?.playerIds); + }, [game.playersModel, game.tournamentsModel, tournamentId]); + const panelImages: Array = [ 'https://via.placeholder.com/300x150', 'https://via.placeholder.com/300x150', @@ -50,7 +73,6 @@ export function Room(): ReactElement { type: ToastType.Info, message: t('toasts.link_copy.message') as string, icon: , - isImmortal: true, }); } catch (e) { addToast({ @@ -58,19 +80,13 @@ export function Room(): ReactElement { type: ToastType.Error, message: t('toasts.link_copy_failed.message') as string, icon: , - isImmortal: true, }); } }; return ( -
- - +
+ -
-
- +
+
+
+ +
-
-
-
+ +
} diff --git a/apps/client/src/pages/room/templates/player-list/PlayerList.template.tsx b/apps/client/src/pages/room/templates/player-list/PlayerList.template.tsx index 851b3ff5..ab23531c 100644 --- a/apps/client/src/pages/room/templates/player-list/PlayerList.template.tsx +++ b/apps/client/src/pages/room/templates/player-list/PlayerList.template.tsx @@ -78,8 +78,8 @@ export function PlayerList({ const max_count = MAX_ALLOWED_PLAYERS.toString(); return ( -
-
+
+
-
+
=> { + initializeSocket(); + socket.emit(SocketProtocols.CreateLobbyRequest, { playerName }); + + const promise: Promise = new Promise((resolve, reject) => { + const receiver = (data: InitialServerData): void => { + // remove `T:` part from the tournament id. + const roomIdFromServer = data.tournamentId.slice(2); + if (roomIdFromServer) { + // For socket communication uses. + savedData.savedRoomId = roomIdFromServer; + savedData.savedPlayerId = data.playerId; + savedData.savedPlayerName = + data.snapshot.playersModel[data.playerId].name; + + // For local store dispatch uses. + savePlayerId(data.playerId); + pubsub.publish(SocketProtocols.CreateLobbyAccept, data); + clearTimeout(waitingTimeout); + resolve(roomIdFromServer); + } else { + reject('Request failed'); + } + }; + socket.once(SocketProtocols.CreateLobbyAccept, receiver); + + const waitingTimeout = setTimeout(() => { + socket.off(SocketProtocols.CreateLobbyAccept, receiver); + reject('Request timed out'); + }, Connection.REQUEST_WAITING_TIME_FOR_CLIENT); + }); + + return promise; +}; diff --git a/apps/client/src/services/handlers/index.ts b/apps/client/src/services/handlers/index.ts new file mode 100644 index 00000000..1d24d9e0 --- /dev/null +++ b/apps/client/src/services/handlers/index.ts @@ -0,0 +1,3 @@ +export * from './create-room'; +export * from './join-room'; +export * from './start-race'; diff --git a/apps/client/src/services/handlers/join-room.ts b/apps/client/src/services/handlers/join-room.ts new file mode 100644 index 00000000..7933e7e5 --- /dev/null +++ b/apps/client/src/services/handlers/join-room.ts @@ -0,0 +1,48 @@ +import { + InitialClientData, + InitialServerData, + SocketProtocols, +} from '@razor/models'; + +import { Connection } from '../../constants'; +import { pubsub } from '../../utils/pubsub'; +import { savePlayerId } from '../../utils/save-player-id'; +import { initializeSocket, savedData, socket } from '../socket-communication'; + +export const requestToJoinRoom = ({ + playerName, + roomId, +}: InitialClientData): Promise => { + initializeSocket(); + socket.emit(SocketProtocols.JoinLobbyRequest, { playerName, roomId }); + + const promise: Promise = new Promise((resolve, reject) => { + const receiver = (data: InitialServerData): void => { + // remove `T:` part from the tournament id. + const roomIdFromServer = data.tournamentId.slice(2); + if (roomIdFromServer) { + // For socket communication uses. + savedData.savedRoomId = roomIdFromServer; + savedData.savedPlayerId = data.playerId; + savedData.savedPlayerName = + data.snapshot.playersModel[data.playerId].name; + + // For local store dispatch uses. + savePlayerId(data.playerId); + pubsub.publish(SocketProtocols.JoinLobbyAccept, data); + clearTimeout(waitingTimeout); + resolve(roomIdFromServer); + } else { + reject('Request failed'); + } + }; + socket.once(SocketProtocols.JoinLobbyAccept, receiver); + + const waitingTimeout = setTimeout(() => { + socket.off(SocketProtocols.JoinLobbyAccept, receiver); + reject('Request timed out'); + }, Connection.REQUEST_WAITING_TIME_FOR_CLIENT); + }); + + return promise; +}; diff --git a/apps/client/src/services/handlers/send-type-log.ts b/apps/client/src/services/handlers/send-type-log.ts new file mode 100644 index 00000000..cd5f6044 --- /dev/null +++ b/apps/client/src/services/handlers/send-type-log.ts @@ -0,0 +1,100 @@ +import { CLIENT_TYPE_LOG_INTERVAL } from '@razor/constants'; +import { + AllProtocolToTypeMap, + PlayerLog, + RaceId, + SocketProtocols, +} from '@razor/models'; +import { store } from '@razor/store'; + +import { getSavedPlayerId } from '../../utils/save-player-id'; +import { socket } from '../socket-communication'; + +class TypeLogsQueue { + private logs: PlayerLog[] = []; + + public addLog(log: PlayerLog): void { + this.logs.push(log); + } + + public getQueue(): PlayerLog[] { + return this.logs; + } + + public clearQueue(): void { + this.logs = []; + } +} + +const typeLogsQueue = new TypeLogsQueue(); + +/** To send a type log to server. + * + * @param lastTypedCharIndex - Last typed character index. + * (Use 0 for the initial type log, which is used to notify the server that the player has started typing.) + */ +export const sendTypeLog = ( + lastTypedCharIndex: number, + raceId: RaceId, +): void => { + const playerLog: PlayerLog = { + timestamp: Date.now(), + textLength: lastTypedCharIndex, + }; + typeLogsQueue.addLog(playerLog); + + // Send type log to local store. + const playerId = getSavedPlayerId(); + if (playerId) { + store.dispatch.game.sendTypeLog({ + playerLog, + raceId, + playerId, + }); + } +}; + +// Send type logs to the server in a specific interval. +export const typeLogPusher = (raceId: RaceId): (() => void) => { + const timer = setInterval(() => { + const logs = typeLogsQueue.getQueue(); + + if (logs.length > 0) { + const data: AllProtocolToTypeMap[SocketProtocols.SendTypeLog] = { + raceId: raceId, + playerLogs: logs, + }; + + socket.emit(SocketProtocols.SendTypeLog, data); + typeLogsQueue.clearQueue(); + } + }, CLIENT_TYPE_LOG_INTERVAL); + + return (): void => { + clearInterval(timer); + }; +}; + +export const sendInitialTypeLog = (raceId: RaceId): void => { + const playerLog: PlayerLog = { + timestamp: Date.now(), + textLength: 0, + }; + + const data: AllProtocolToTypeMap[SocketProtocols.SendTypeLog] = { + raceId: raceId, + playerLogs: [playerLog], + }; + + socket.emit(SocketProtocols.SendTypeLog, data); + + // Send type log to local store. + const playerId = getSavedPlayerId(); + if (playerId) { + store.dispatch.game.sendTypeLog({ + playerLog, + raceId, + playerId, + }); + } +}; diff --git a/apps/client/src/services/handlers/start-race.ts b/apps/client/src/services/handlers/start-race.ts new file mode 100644 index 00000000..ab1a0807 --- /dev/null +++ b/apps/client/src/services/handlers/start-race.ts @@ -0,0 +1,25 @@ +import { SocketProtocols } from '@razor/models'; + +import { Connection } from '../../constants'; +import { socket } from '../socket-communication'; + +export function requestToStartRace(): Promise { + socket.emit(SocketProtocols.StartRaceRequest, {}); + + const promise: Promise = new Promise((resolve, reject) => { + const receiver = (): void => { + socket.off(SocketProtocols.StartRaceAccept, receiver); + clearTimeout(waitingTimeout); + resolve(); + }; + socket.once(SocketProtocols.StartRaceAccept, receiver); + + const waitingTimeout = setTimeout(() => { + socket.off(SocketProtocols.StartRaceAccept, receiver); + // TODO: Error classes and error handler for timeout, disconnect, etc. + reject('Request timed out'); + }, Connection.REQUEST_WAITING_TIME_FOR_CLIENT); + }); + + return promise; +} diff --git a/apps/client/src/services/socket-communication.ts b/apps/client/src/services/socket-communication.ts index 62a14d6a..afa809f9 100644 --- a/apps/client/src/services/socket-communication.ts +++ b/apps/client/src/services/socket-communication.ts @@ -1,26 +1,37 @@ -import { RECONNECT_WAITING_TIME, REQUEST_WAITING_TIME } from '@razor/constants'; +import { RECONNECT_WAITING_TIME } from '@razor/constants'; import { AuthToken, - InitialClientData, - InitialServerData, - playerIdSchema, + protocolToSchemaMap, SocketProtocols, - stateModelSchema, - tournamentIdSchema, + SocketProtocolsTypes, } from '@razor/models'; import { roomIdToTournamentId } from '@razor/util'; import { io, Socket } from 'socket.io-client'; +import { Connection } from '../constants'; import { ClientUniqueEvents, SendDataToServerModel } from '../models'; import { pubsub } from '../utils/pubsub'; -import { savePlayerId } from '../utils/save-player-id'; + +import { requestToJoinRoom } from './handlers/join-room'; const SOCKET_ENDPOINT = - import.meta.env.NX_SOCKET_ENDPOINT || 'http://localhost:3000'; -let authToken = ''; -let savedPlayerName = ''; -let savedPlayerId = ''; -let savedRoomId = ''; + import.meta.env.VITE_SOCKET_ENDPOINT || 'http://localhost:3000'; + +/** This class is used to save data for socket communication, + * which is used to reconnect to the server when the connection is lost. + */ +class SavedData { + public authToken = ''; + public savedPlayerName = ''; + public savedPlayerId = ''; + public savedRoomId = ''; + + public setAuthToken(token: AuthToken): void { + this.authToken = token; + } +} + +export const savedData = new SavedData(); interface SocketFormat extends Socket { auth: { @@ -30,7 +41,7 @@ interface SocketFormat extends Socket { export const socket = io(SOCKET_ENDPOINT, { auth: { - token: authToken, + token: savedData.authToken, }, autoConnect: false, withCredentials: true, @@ -38,14 +49,14 @@ export const socket = io(SOCKET_ENDPOINT, { export const endSocket = (): void => { socket.disconnect(); - authToken = ''; - savedPlayerName = ''; - savedPlayerId = ''; - savedRoomId = ''; + savedData.authToken = ''; + savedData.savedPlayerName = ''; + savedData.savedPlayerId = ''; + savedData.savedRoomId = ''; }; -const initializeSocket = (): void => { - socket.auth.token = authToken; +export const initializeSocket = (): void => { + socket.auth.token = savedData.authToken; socket.connect(); socket.on('connect_error', () => { alert('Connection error'); @@ -55,7 +66,7 @@ const initializeSocket = (): void => { console.log('connected'); }); socket.on(SocketProtocols.AuthTokenTransfer, (token: string) => { - authToken = token; + savedData.authToken = token; }); }; @@ -64,27 +75,27 @@ const tryReconnect = (reason: Socket.DisconnectReason): void => { // Connection issue is considered as a unintentional disconnect. // https://socket.io/docs/v3/client-socket-instance/#disconnect if (reason !== 'io server disconnect' && reason !== 'io client disconnect') { - if (savedRoomId && savedPlayerName) { + if (savedData.savedRoomId && savedData.savedPlayerName) { const reconnector = setInterval(async () => { try { console.log('Reconnecting...'); await requestToJoinRoom({ - playerName: savedPlayerName, - roomId: savedRoomId, + playerName: savedData.savedPlayerName, + roomId: savedData.savedRoomId, }); clearInterval(reconnector); } catch (error) { console.error(error); } - }, REQUEST_WAITING_TIME); + }, Connection.REQUEST_WAITING_TIME_FOR_CLIENT); // If the user doesn't reconnect in RECONNECT_WAITING_TIME, stop trying. const waitingTimeout = setTimeout(() => { clearInterval(reconnector); - authToken = ''; - savedPlayerName = ''; - savedPlayerId = ''; - savedRoomId = ''; + savedData.authToken = ''; + savedData.savedPlayerName = ''; + savedData.savedPlayerId = ''; + savedData.savedRoomId = ''; // TODO: navigate to home page }, RECONNECT_WAITING_TIME); @@ -99,96 +110,47 @@ const tryReconnect = (reason: Socket.DisconnectReason): void => { socket.on('disconnect', reason => tryReconnect(reason)); -export const requestToJoinRoom = ({ - playerName, - roomId, -}: InitialClientData): Promise => { - initializeSocket(); - socket.emit(SocketProtocols.JoinLobbyRequest, { playerName, roomId }); - return new Promise((resolve, reject) => { - const receiver = (data: InitialServerData): void => { - // Data validation - const validation = - !stateModelSchema.safeParse(data.snapshot).success || - !tournamentIdSchema.safeParse(data.tournamentId).success || - !playerIdSchema.safeParse(data.playerId).success; - if (validation) { - reject('Invalid data'); - return; - } - - // remove `T:` part from the tournament id. - const roomIdFromServer = data.tournamentId.slice(2); - if (roomIdFromServer) { - savedRoomId = roomIdFromServer; - savedPlayerId = data.playerId; - savedPlayerName = data.snapshot.playersModel[data.playerId].name; - - savePlayerId(data.playerId); - pubsub.publish(SocketProtocols.JoinLobbyAccept, data); - clearTimeout(waitingTimeout); - resolve(roomIdFromServer); - } else { - reject('Request failed'); - } - }; - socket.once(SocketProtocols.JoinLobbyAccept, receiver); - - const waitingTimeout = setTimeout(() => { - socket.off(SocketProtocols.JoinLobbyAccept, receiver); - reject('Request timed out'); - }, REQUEST_WAITING_TIME); - }); -}; +interface validateSchemaArgs { + event: SocketProtocolsTypes; + data: T; +} -export const requestToCreateRoom = ({ - playerName, -}: InitialClientData): Promise => { - initializeSocket(); - socket.emit(SocketProtocols.CreateLobbyRequest, { playerName }); - return new Promise((resolve, reject) => { - const receiver = (data: InitialServerData): void => { - // Data validation - const validation = - !stateModelSchema.safeParse(data.snapshot).success || - !tournamentIdSchema.safeParse(data.tournamentId).success || - !playerIdSchema.safeParse(data.playerId).success; - if (validation) { - reject('Invalid data'); - return; - } - - // remove `T:` part from the tournament id. - const roomIdFromServer = data.tournamentId.slice(2); - if (roomIdFromServer) { - savedRoomId = roomIdFromServer; - savedPlayerId = data.playerId; - savedPlayerName = data.snapshot.playersModel[data.playerId].name; - - savePlayerId(data.playerId); - pubsub.publish(SocketProtocols.CreateLobbyAccept, data); - clearTimeout(waitingTimeout); - resolve(roomIdFromServer); - } else { - reject('Request failed'); - } - }; - socket.once(SocketProtocols.CreateLobbyAccept, receiver); - - const waitingTimeout = setTimeout(() => { - socket.off(SocketProtocols.CreateLobbyAccept, receiver); - reject('Request timed out'); - }, REQUEST_WAITING_TIME); - }); -}; +function validateSchema({ event, data }: validateSchemaArgs): boolean { + try { + const schema = protocolToSchemaMap.get(event); + if (!schema) { + console.log(`Unrecognized event: ${event}`); + return false; + } + + schema.parse(data); + return true; + } catch (error) { + // TODO: implement logger service for client + console.log(`Received data invalid. (zod-error) ${error}`, { + protocolName: event, + protocolData: data, + }); + return false; + } +} socket.onAny((event, data) => { + const isValid = validateSchema({ event, data }); + if (!isValid) { + return; + } + if ( event !== SocketProtocols.CreateLobbyAccept && event !== SocketProtocols.JoinLobbyAccept ) { - const tournamentId = roomIdToTournamentId(savedRoomId); - pubsub.publish(event, { tournamentId, savedPlayerId, data }); + const tournamentId = roomIdToTournamentId(savedData.savedRoomId); + pubsub.publish(event, { + tournamentId, + savedPlayerId: savedData.savedPlayerId, + data, + }); console.log(event, data); } }); diff --git a/apps/client/src/services/socket/start-race.ts b/apps/client/src/services/socket/start-race.ts deleted file mode 100644 index e7c922c1..00000000 --- a/apps/client/src/services/socket/start-race.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { SocketProtocols } from '@razor/models'; - -import { socket } from '../socket-communication'; - -export function startRace(): void { - socket.emit(SocketProtocols.StartRaceRequest, {}); -} diff --git a/apps/client/src/utils/pubsub.ts b/apps/client/src/utils/pubsub.ts index c1922a11..4466ebc8 100644 --- a/apps/client/src/utils/pubsub.ts +++ b/apps/client/src/utils/pubsub.ts @@ -1,5 +1,5 @@ import { PubSub } from '@razor/util'; -import { AllServerPubSubEventsToTypeMap } from '../models'; +import { AllClientPubSubEventsToTypeMap } from '../models'; -export const pubsub = new PubSub(); +export const pubsub = new PubSub(); diff --git a/apps/client/src/utils/save-player-id.ts b/apps/client/src/utils/save-player-id.ts index 5da551e3..a6cfcb70 100644 --- a/apps/client/src/utils/save-player-id.ts +++ b/apps/client/src/utils/save-player-id.ts @@ -2,6 +2,7 @@ import { AppPlayerId } from '@razor/models'; let savedPlayerId: AppPlayerId | null; +/** Save the player ID for local store dispatch uses. */ export function savePlayerId(playerId: AppPlayerId | null = null): void { savedPlayerId = playerId; } diff --git a/apps/server/src/controllers/index.ts b/apps/server/src/controllers/index.ts index cb2a1346..93ac6c77 100644 --- a/apps/server/src/controllers/index.ts +++ b/apps/server/src/controllers/index.ts @@ -2,3 +2,4 @@ export * from './clear-player.controller'; export * from './create-tournament.controller'; export * from './join-tournament.controller'; export * from './start-race.controller'; +export * from './update-type-logs.controller'; diff --git a/apps/server/src/controllers/start-race.controller.ts b/apps/server/src/controllers/start-race.controller.ts index 51a4621c..86597f31 100644 --- a/apps/server/src/controllers/start-race.controller.ts +++ b/apps/server/src/controllers/start-race.controller.ts @@ -6,6 +6,7 @@ import { AllServerPubSubEventsToTypeMap, PubSubEvents, RaceTimeoutModel, + TypeLogListeningModel, } from '../models'; import { Logger, publishToAllClients, pubsub } from '../services'; import { generateRaceText } from '../utils'; @@ -20,8 +21,23 @@ export const startRaceController = async ({ playerId, tournamentId, }: StartRaceArgs): Promise => { + let game = store.getState().game; + let tournament = game.tournamentsModel[tournamentId]; + + // Check for ongoing races. If there's an ongoing race dismiss the request. + let raceIds = tournament.raceIds; + let raceId = raceIds[raceIds.length - 1] || null; + let race = raceId ? game.racesModel[raceId] : null; + + if (race?.isOnGoing) { + logger.info( + 'The start race request has been dismissed because the tournament currently contains an ongoing race.', + context, + { onGoingRaceId: raceId }, + ); + } + // Check for two active players in the tournament. - const tournament = store.getState().game.tournamentsModel[tournamentId]; if (tournament.playerIds.length < 2) { logger.error( 'Tournament does not have at least two active players to start a race.', @@ -47,12 +63,12 @@ export const startRaceController = async ({ logger.info('Race started', context, { startedBy: playerId }); - const game = store.getState().game; - const tournamentModel = game.tournamentsModel[tournamentId]; - const raceIds = tournamentModel.raceIds; + game = store.getState().game; + tournament = game.tournamentsModel[tournamentId]; + raceIds = tournament.raceIds; // Active race id - const raceId = raceIds[raceIds.length - 1]; - const race = game.racesModel[raceId]; + raceId = raceIds[raceIds.length - 1]; + race = game.racesModel[raceId]; const startedRaceData: StartRaceAcceptData = { raceId, @@ -66,13 +82,18 @@ export const startRaceController = async ({ data: startedRaceData, }); - const raceEndTime = (race.timeoutDuration + RACE_END_WAIT_TIME) * 1000; + // Publish start type log listening event + const TypeLogListenData: TypeLogListeningModel = { + context, + data: { raceId }, + }; + pubsub.publish(PubSubEvents.StartTypeLogListening, TypeLogListenData); + const raceEndTime = (race.timeoutDuration + RACE_END_WAIT_TIME) * 1000; const raceTimeoutData: RaceTimeoutModel = { context, data: { raceId }, }; - const raceTimeout = setTimeout(() => { pubsub.publish(PubSubEvents.RaceTimeout, raceTimeoutData); clearTimeout(raceTimeout); diff --git a/apps/server/src/controllers/update-type-logs.controller/index.ts b/apps/server/src/controllers/update-type-logs.controller/index.ts new file mode 100644 index 00000000..3faaf6e7 --- /dev/null +++ b/apps/server/src/controllers/update-type-logs.controller/index.ts @@ -0,0 +1,2 @@ +export * from './listen-to-client'; +export * from './send-logs-to-players'; diff --git a/apps/server/src/controllers/update-type-logs.controller/listen-to-client.ts b/apps/server/src/controllers/update-type-logs.controller/listen-to-client.ts new file mode 100644 index 00000000..b44eaf7d --- /dev/null +++ b/apps/server/src/controllers/update-type-logs.controller/listen-to-client.ts @@ -0,0 +1,41 @@ +import { playerLogsToAppPlayerLogs, SocketProtocols } from '@razor/models'; +import { store } from '@razor/store'; + +import { AllServerPubSubEventsToTypeMap } from '../../models'; +import { Logger, pubsub } from '../../services'; + +import { getTypeLogsQueue } from './type-log-queues'; + +type SendTypeLogControllerArgs = + AllServerPubSubEventsToTypeMap[SocketProtocols.SendTypeLog]; + +const logger = new Logger('update-type-log.controller/listen-to-client'); + +export const sendTypeLogController = ({ + data, + context, + playerId, +}: SendTypeLogControllerArgs): void => { + const { raceId, playerLogs } = data; + + // Check whether race is ongoing + const race = store.getState().game.racesModel[raceId]; + const isRaceOngoing = race.isOnGoing; + + if (!isRaceOngoing) { + logger.warn('Received a type log when specific race is ended.', context); + return; + } + + store.dispatch.game.sendTypeLog({ + raceId, + playerId, + playerLog: playerLogsToAppPlayerLogs(playerLogs), + }); + + const typeLogsQueue = getTypeLogsQueue(raceId, context); + typeLogsQueue.addLog(playerLogs, playerId); + logger.debug('Type logs are added to race type-logs-queue.', context); +}; + +pubsub.subscribe(SocketProtocols.SendTypeLog, sendTypeLogController); diff --git a/apps/server/src/controllers/update-type-logs.controller/send-logs-to-players.ts b/apps/server/src/controllers/update-type-logs.controller/send-logs-to-players.ts new file mode 100644 index 00000000..1b2acdf8 --- /dev/null +++ b/apps/server/src/controllers/update-type-logs.controller/send-logs-to-players.ts @@ -0,0 +1,85 @@ +import { SERVER_TYPE_LOG_INTERVAL } from '@razor/constants'; +import { AllProtocolToTypeMap, RaceId, SocketProtocols } from '@razor/models'; +import { extractId, ExtractIdType } from '@razor/util'; + +import { AllServerPubSubEventsToTypeMap, PubSubEvents } from '../../models'; +import { + ContextOutput, + Logger, + publishToAllClients, + pubsub, +} from '../../services'; + +import { clearTypeLogsQueue, getTypeLogsQueue } from './type-log-queues'; + +const logger = new Logger('update-type-log.controller/send-logs-to-players'); + +/** + * Sends the collected logs to all players at a specific interval. + */ +export const typeLogPusher = ( + raceId: RaceId, + context: ContextOutput, +): (() => void) => { + const timer = setInterval(() => { + const logsQueue = getTypeLogsQueue(raceId, context); + const logsCollection = logsQueue.getLogsCollection(); + const isLogsCollectionEmpty = + logsCollection == null || Object.keys(logsCollection).length === 0; + + if (!isLogsCollectionEmpty) { + const data: AllProtocolToTypeMap[SocketProtocols.UpdateTypeLogs] = { + raceId: raceId, + playerLogs: logsCollection, + }; + + const tournamentId = extractId( + raceId, + ExtractIdType.Race, + ExtractIdType.Tournament, + ); + + publishToAllClients({ + tournamentId: tournamentId, + protocol: SocketProtocols.UpdateTypeLogs, + data, + }); + logsQueue.clearQueue(); + } + }, SERVER_TYPE_LOG_INTERVAL); + + return (): void => { + clearInterval(timer); + clearTypeLogsQueue(raceId); + logger.info( + 'Type log pusher destroyed and queue cleared after race end.', + context, + ); + }; +}; + +const flushAllAfterRaceEnd = ( + destroyTypeLogPusher: () => void, + context: ContextOutput, +): void => { + pubsub.unsubscribe(PubSubEvents.StartTypeLogListening, destroyTypeLogPusher); + pubsub.unsubscribe(PubSubEvents.RaceTimeout, (_): void => + flushAllAfterRaceEnd(destroyTypeLogPusher, context), + ); + destroyTypeLogPusher(); +}; + +type StartTypeLogListeningArgs = + AllServerPubSubEventsToTypeMap[PubSubEvents.StartTypeLogListening]; +const typeLogPushController = ({ + data, + context, +}: StartTypeLogListeningArgs): void => { + const { raceId } = data; + const destroyTypeLogPusher = typeLogPusher(raceId, context); + pubsub.subscribe(PubSubEvents.RaceTimeout, (_): void => + flushAllAfterRaceEnd(destroyTypeLogPusher, context), + ); +}; + +pubsub.subscribe(PubSubEvents.StartTypeLogListening, typeLogPushController); diff --git a/apps/server/src/controllers/update-type-logs.controller/type-log-queues.ts b/apps/server/src/controllers/update-type-logs.controller/type-log-queues.ts new file mode 100644 index 00000000..517680f6 --- /dev/null +++ b/apps/server/src/controllers/update-type-logs.controller/type-log-queues.ts @@ -0,0 +1,78 @@ +import { + PlayerId, + PlayerLog, + PlayerLogsCollection, + playerLogsToAppPlayerLogs, + RaceId, +} from '@razor/models'; + +import { ContextOutput, Logger } from '../../services'; + +const logger = new Logger('update-type-log.controller/type-log-queues'); + +/** Keeping type logs queues per every race on the server. */ +const typeLogsQueuesForRaces = new Map(); + +/** This class contains player-type-logs for specific race. */ +class TypeLogsQueue { + private logsCollection: PlayerLogsCollection = {}; + + /** Add player logs to queue */ + public addLog(log: PlayerLog[], playerId: PlayerId): void { + if (!this.logsCollection[playerId]) { + this.logsCollection[playerId] = []; + } + this.logsCollection[playerId].push(...playerLogsToAppPlayerLogs(log)); + } + + /** This method will return logs collection stored inside the class. */ + public getLogsCollection(): PlayerLogsCollection { + return this.logsCollection; + } + + /** Clear the race logs queue */ + public clearQueue(): void { + this.logsCollection = {}; + } +} + +/** + * Create a new type log queue instance for the race + * @param raceId id of race that the queue is for + * @returns new instance of TypeLogsQueue + */ +const createTypeLogsQueue = ( + raceId: RaceId, + context: ContextOutput, +): TypeLogsQueue => { + const typeLogsQueue = new TypeLogsQueue(); + typeLogsQueuesForRaces.set(raceId, typeLogsQueue); + + logger.debug('New type-logs-queue created for the race.', context); + return typeLogsQueue; +}; + +/** + * Get the type-log-queue instance for the race. + * If the queue doesn't exist, create a new one. + * @param raceId id of the race that the queue is for + * @returns instance of TypeLogsQueue + */ +export const getTypeLogsQueue = ( + raceId: RaceId, + context: ContextOutput, +): TypeLogsQueue => { + if (!typeLogsQueuesForRaces.has(raceId)) { + return createTypeLogsQueue(raceId, context); + } + return typeLogsQueuesForRaces.get(raceId); +}; + +/** + * Clear specific type-log-queue instance. + * @param raceId id of the race which queue should be cleared + */ +export const clearTypeLogsQueue = (raceId: RaceId): void => { + typeLogsQueuesForRaces.get(raceId).clearQueue(); + delete typeLogsQueuesForRaces[raceId]; +}; diff --git a/apps/server/src/models/pubsub-events.ts b/apps/server/src/models/pubsub-events.ts index c09cb819..b647cb3a 100644 --- a/apps/server/src/models/pubsub-events.ts +++ b/apps/server/src/models/pubsub-events.ts @@ -29,6 +29,13 @@ export enum PubSubEvents { * and it should subscribe by race-end controller. */ RaceTimeout = 'race-timeout', + + /** Start and end events for type log listening. + * Which push collected type logs to players at a specific interval + * and clear type logs queue. + */ + StartTypeLogListening = 'type-log-listen-start', + EndTypeLogListening = 'type-log-listen-end', } // Models @@ -54,11 +61,18 @@ export interface RaceTimeoutModel { context: ContextOutput; } +export interface TypeLogListeningModel { + data: { raceId: RaceId }; + context: ContextOutput; +} + export interface ServerUniqueEventsToTypeMap extends Record { [PubSubEvents.SendDataToClient]: SendDataToClientModel; [PubSubEvents.SendDataToAll]: SendDataToAllModel; [PubSubEvents.PlayerDisconnect]: PlayerDisconnectModel; [PubSubEvents.RaceTimeout]: RaceTimeoutModel; + [PubSubEvents.StartTypeLogListening]: TypeLogListeningModel; + [PubSubEvents.EndTypeLogListening]: TypeLogListeningModel; } type ModifiedEvent = { diff --git a/docs/communication/protocol/send-type-log.md b/docs/communication/protocol/send-type-log.md index 17577250..588e76ab 100644 --- a/docs/communication/protocol/send-type-log.md +++ b/docs/communication/protocol/send-type-log.md @@ -26,12 +26,8 @@ _This socket protocol will use to send the race start type log (`textLength` wil ```json "type": "TS/INF/SEND_TYPE_LOG" "data": { - "race-id": RACE_ID, - "player-log": { - // Server will get player id using session and socket id. - "textLength": 0, - "timestamp": 0 - } + "raceId": RACE_ID, + "playerLogs": [] } ``` @@ -41,29 +37,19 @@ _This socket protocol will use to send the race start type log (`textLength` wil "type": "FS_ALL/INF/UPDATE_TYPE_LOGS" "data": { "raceId": RACE_ID, - "playersWithLogs": [] + "playersWithLogs": } ``` ### **PlayerLogsCollection** ```ts -// Server sends player logs packet by packet, as the race continues. -interface PlayerLogsCollection { - id: string; - name: string; - avatarLink: string; - logs: [ - { - textLength: number, - timestamp: number - } - ... - ]; - // Keeping last timestamp will make easy to merge updates from server to client. - lastTimestamp: number; +interface PlayerLog { + textLength: number, + timestamp: number } +interface PlayerLogsCollection = Record; ``` references: [Data Models](../../../../libs/models/src/lib/sockets) diff --git a/libs/constants/src/index.ts b/libs/constants/src/index.ts index faf329c1..184bf9a8 100644 --- a/libs/constants/src/index.ts +++ b/libs/constants/src/index.ts @@ -1,4 +1,5 @@ export * from './lib/client'; export * from './lib/constants'; +export * from './lib/race'; export * from './lib/room'; export * from './lib/socket'; diff --git a/libs/constants/src/lib/client.ts b/libs/constants/src/lib/client.ts index a45a5217..f0033ad5 100644 --- a/libs/constants/src/lib/client.ts +++ b/libs/constants/src/lib/client.ts @@ -1,2 +1,5 @@ // Waiting time for server response. -export const REQUEST_WAITING_TIME = 4000; +export const REQUEST_WAITING_TIME = 8000; + +// Client type log sending interval. +export const CLIENT_TYPE_LOG_INTERVAL = 1000; diff --git a/libs/constants/src/lib/race.ts b/libs/constants/src/lib/race.ts new file mode 100644 index 00000000..58d3bbca --- /dev/null +++ b/libs/constants/src/lib/race.ts @@ -0,0 +1,2 @@ +// Server type log sending interval. +export const SERVER_TYPE_LOG_INTERVAL = 1000; diff --git a/libs/models/src/lib/converters/player.ts b/libs/models/src/lib/converters/player.ts index 9f30e421..61b54686 100644 --- a/libs/models/src/lib/converters/player.ts +++ b/libs/models/src/lib/converters/player.ts @@ -1,5 +1,5 @@ -import { PlayerState } from '../sockets'; -import { AppPlayerState } from '../state'; +import { PlayerLog, PlayerState } from '../sockets'; +import { AppPlayerLog, AppPlayerState } from '../state'; export function appPlayerStateToPlayerState( state: AppPlayerState, @@ -12,3 +12,7 @@ export function playerStateToAppPlayerState( ): AppPlayerState { return state as unknown as AppPlayerState; } + +export function playerLogsToAppPlayerLogs(logs: PlayerLog[]): AppPlayerLog[] { + return logs as unknown as AppPlayerLog[]; +} diff --git a/libs/models/src/lib/sockets/player.ts b/libs/models/src/lib/sockets/player.ts index fd7f1fb5..223fea64 100644 --- a/libs/models/src/lib/sockets/player.ts +++ b/libs/models/src/lib/sockets/player.ts @@ -48,3 +48,4 @@ export const playerSchema = z.object({ // ==== Interfaces ==== // export type Player = z.infer; +export type PlayerProfile = Omit; diff --git a/libs/models/src/lib/sockets/playerLog.ts b/libs/models/src/lib/sockets/playerLog.ts index 10cfefed..de058bf6 100644 --- a/libs/models/src/lib/sockets/playerLog.ts +++ b/libs/models/src/lib/sockets/playerLog.ts @@ -1,32 +1,15 @@ import { MAX_ALLOWED_TEXT_LENGTH } from '@razor/constants'; import { z } from 'zod'; -import { Player } from './player'; +import { playerIdSchema } from './player'; import { timestampSchema } from './tournament'; // ==== Types ==== // /** Type for time-logs when players are typing */ -export type PlayerLog = z.input; +export type PlayerLog = z.infer; -// ==== Interfaces ==== // -// Note: `PlayerLogsCollection` and `PlayerLogsPacket` does not need to be a schema; because it's only bound to the server-to-client communication. -/** Log collection for a specific player in a specific race. - * - * Keeping player id, name, and avatar link inside race to show data even if a player leaves. - */ -export interface PlayerLogsCollection extends Omit { - /** Array of time logs of player */ - logs: PlayerLog[]; -} - -/** Server sends player logs packet by packet, as the race continues. */ -export interface PlayerLogsPacket extends PlayerLogsCollection { - /** Last timestamp of player log that was sent to the viewer. - * - * Keeping last timestamp will make easy to merge updates from server to client. - */ - lastTimestamp: number; -} +/** Log collection or partial collection for a specific player. */ +export type PlayerLogsCollection = z.infer; // ==== Primary Schemas ==== // export const playerLogIdSchema = @@ -41,3 +24,8 @@ export const playerLogSchema = z.object({ /** Timestamp when player typed the last character */ timestamp: timestampSchema, }); + +export const playerLogsCollectionSchema = z.record( + playerIdSchema, + z.array(playerLogSchema), +); diff --git a/libs/models/src/lib/sockets/protocol-data.ts b/libs/models/src/lib/sockets/protocol-data.ts index 75443ded..e54a65b8 100644 --- a/libs/models/src/lib/sockets/protocol-data.ts +++ b/libs/models/src/lib/sockets/protocol-data.ts @@ -6,8 +6,10 @@ import { PlayerId } from './player'; import { initialClientDataSchema, playerJoinSchema, + sendTypeLogSchema, startRaceAcceptSchema, startRaceRequestSchema, + updateTypeLogsSchema, } from './protocol-schemas'; import { TournamentId } from './tournament'; @@ -44,3 +46,6 @@ export type PlayerJoinData = z.infer; export type StartRaceRequestData = z.infer; export type StartRaceAcceptData = z.infer; + +export type SendTypeLogData = z.infer; +export type UpdateTypeLogsData = z.infer; diff --git a/libs/models/src/lib/sockets/protocol-schemas.ts b/libs/models/src/lib/sockets/protocol-schemas.ts index 8ea876da..93904e08 100644 --- a/libs/models/src/lib/sockets/protocol-schemas.ts +++ b/libs/models/src/lib/sockets/protocol-schemas.ts @@ -1,7 +1,9 @@ import { z } from 'zod'; import { playerIdSchema, playerNameSchema, playerSchema } from './player'; -// eslint-disable-next-line @typescript-eslint/no-unused-vars +import { playerLogSchema, playerLogsCollectionSchema } from './playerLog'; +// eslint-disable-next-line unused-imports/no-unused-imports +import { SocketProtocols } from './protocols'; import { raceIdSchema } from './race'; import { stateModelSchema } from './state-model'; import { roomIdSchema, tournamentIdSchema } from './tournament'; @@ -9,6 +11,11 @@ import { roomIdSchema, tournamentIdSchema } from './tournament'; // Following schemas to be used when data sent through socket. // Each schema is related to a protocol defined in {@link SocketProtocols} +/** + * Related protocol - {@link SocketProtocols.AuthTokenTransfer} + */ +export const authTokenTransferSchema = z.string(); + /** * Related protocol - {@link SocketProtocols.JoinLobbyRequest} and {@link SocketProtocols.CreateLobbyRequest} */ @@ -41,16 +48,34 @@ export const startRaceRequestSchema = z.object({}); /** * Related protocol - {@link SocketProtocols.StartRaceAccept} */ - export const startRaceAcceptSchema = z.object({ raceId: raceIdSchema, raceStartedBy: playerIdSchema, raceText: z.string(), }); +/** + * Related protocol - {@link SocketProtocols.SendTypeLog} + */ +export const sendTypeLogSchema = z.object({ + raceId: raceIdSchema, + playerLogs: z.array(playerLogSchema), +}); + +/** + * Related protocol - {@link SocketProtocols.UpdateTypeLogs} + */ +export const updateTypeLogsSchema = z.object({ + raceId: raceIdSchema, + playerLogs: playerLogsCollectionSchema, +}); + export type ProtocolSchemaTypes = + | typeof authTokenTransferSchema | typeof initialClientDataSchema | typeof initialServerDataSchema | typeof playerJoinSchema | typeof startRaceRequestSchema - | typeof startRaceAcceptSchema; + | typeof startRaceAcceptSchema + | typeof sendTypeLogSchema + | typeof updateTypeLogsSchema; diff --git a/libs/models/src/lib/sockets/protocol-to-schema-map.ts b/libs/models/src/lib/sockets/protocol-to-schema-map.ts index 4f39a376..165511d5 100644 --- a/libs/models/src/lib/sockets/protocol-to-schema-map.ts +++ b/libs/models/src/lib/sockets/protocol-to-schema-map.ts @@ -1,8 +1,13 @@ import { + authTokenTransferSchema, initialClientDataSchema, initialServerDataSchema, + playerJoinSchema, ProtocolSchemaTypes, + sendTypeLogSchema, + startRaceAcceptSchema, startRaceRequestSchema, + updateTypeLogsSchema, } from './protocol-schemas'; import { SocketProtocols, SocketProtocolsTypes } from './protocols'; @@ -11,9 +16,14 @@ export const protocolToSchemaMap = new Map< SocketProtocolsTypes, ProtocolSchemaTypes >([ + [SocketProtocols.AuthTokenTransfer, authTokenTransferSchema], [SocketProtocols.JoinLobbyRequest, initialClientDataSchema], [SocketProtocols.JoinLobbyAccept, initialServerDataSchema], [SocketProtocols.CreateLobbyRequest, initialClientDataSchema], [SocketProtocols.CreateLobbyAccept, initialServerDataSchema], + [SocketProtocols.PlayerJoin, playerJoinSchema], [SocketProtocols.StartRaceRequest, startRaceRequestSchema], + [SocketProtocols.StartRaceAccept, startRaceAcceptSchema], + [SocketProtocols.SendTypeLog, sendTypeLogSchema], + [SocketProtocols.UpdateTypeLogs, updateTypeLogsSchema], ]); diff --git a/libs/models/src/lib/sockets/protocol-to-type-map.ts b/libs/models/src/lib/sockets/protocol-to-type-map.ts index 9b516913..66b87d80 100644 --- a/libs/models/src/lib/sockets/protocol-to-type-map.ts +++ b/libs/models/src/lib/sockets/protocol-to-type-map.ts @@ -2,7 +2,10 @@ import { InitialClientData, InitialServerData, PlayerJoinData, + SendTypeLogData, + StartRaceAcceptData, StartRaceRequestData, + UpdateTypeLogsData, } from './protocol-data'; import { SocketProtocols } from './protocols'; @@ -15,8 +18,11 @@ export interface InitialProtocolToTypeMap extends Record { } export interface OtherProtocolToTypeMap extends Record { - [SocketProtocols.StartRaceRequest]: StartRaceRequestData; [SocketProtocols.PlayerJoin]: PlayerJoinData; + [SocketProtocols.StartRaceRequest]: StartRaceRequestData; + [SocketProtocols.StartRaceAccept]: StartRaceAcceptData; + [SocketProtocols.SendTypeLog]: SendTypeLogData; + [SocketProtocols.UpdateTypeLogs]: UpdateTypeLogsData; } export type AllProtocolToTypeMap = InitialProtocolToTypeMap & diff --git a/libs/models/src/lib/sockets/race.ts b/libs/models/src/lib/sockets/race.ts index 468e2b1e..b4722c4b 100644 --- a/libs/models/src/lib/sockets/race.ts +++ b/libs/models/src/lib/sockets/race.ts @@ -1,5 +1,6 @@ import { z } from 'zod'; +import { PlayerId, PlayerProfile } from './player'; import { PlayerLogsCollection } from './playerLog'; // ==== Primary Schemas ==== // @@ -26,8 +27,10 @@ export interface Race { * It can be empty before players send the starting message to the server. */ playerLogs: PlayerLogsCollection[]; + /** Player profiles */ + playerProfiles: PlayerProfile[]; /** Player who pressed the start button */ - raceStartedBy: string; + raceStartedBy: PlayerId; } // ==== Types ==== // diff --git a/libs/store/src/lib/effects/player.spec.ts b/libs/store/src/lib/effects/player.spec.ts index 633fdb7a..ddbfb149 100644 --- a/libs/store/src/lib/effects/player.spec.ts +++ b/libs/store/src/lib/effects/player.spec.ts @@ -457,6 +457,80 @@ describe('[Effects] Player Log', () => { game: expectedResult, }); }); + it('(race id & player id; type logs [Array]) => Send Type Log', () => { + const initialValues: AppStateModel = { + ...initialState, + tournamentsModel: { + [M_TOURNAMENT_ID0]: { + ...M_TOURNAMENT0, + state: AppTournamentState.Race, + }, + }, + racesModel: { + [M_TR0_RACE_ID0]: mockRace([0, 3], true), + }, + playersModel: mockPlayersModel( + [0, 3], + M_TOURNAMENT_ID0, + AppPlayerState.Racing, + ), + }; + const store = initializeStore(initialValues); + const initialStoreState = store.getState(); + + store.dispatch.game.sendTypeLog({ + playerId: M_PLAYER_ID0, + raceId: M_TR0_RACE_ID0, + playerLog: [ + { + textLength: 0, + timestamp: 123456000, + }, + { + textLength: 1, + timestamp: 123456002, + }, + { + textLength: 2, + timestamp: 123456020, + }, + { + textLength: 3, + timestamp: 123456030, + }, + ], + }); + const storeState = store.getState(); + + const expectedResult: AppStateModel = { + ...initialValues, + playerLogsModel: { + [`${M_TR0_RACE_ID0}-${M_PLAYER_ID0}`]: [ + { + textLength: 0, + timestamp: 123456000, + }, + { + textLength: 1, + timestamp: 123456002, + }, + { + textLength: 2, + timestamp: 123456020, + }, + { + textLength: 3, + timestamp: 123456030, + }, + ], + }, + }; + + expect(storeState).toEqual({ + ...initialStoreState, + game: expectedResult, + }); + }); it('(not existing player) => Raise error', () => { const initialValues: AppStateModel = { ...initialState, diff --git a/libs/store/src/lib/payloads/effects.ts b/libs/store/src/lib/payloads/effects.ts index 01e8bd93..db6a55b1 100644 --- a/libs/store/src/lib/payloads/effects.ts +++ b/libs/store/src/lib/payloads/effects.ts @@ -48,7 +48,7 @@ export type SendTypeLogPayload = { raceId: AppRaceId; playerId: AppPlayerId; /** Timestamp, and text length from players machine */ - playerLog: AppPlayerLog; + playerLog: AppPlayerLog | AppPlayerLog[]; }; export type ReplaceFullStatePayload = { diff --git a/libs/store/src/lib/payloads/reducers.ts b/libs/store/src/lib/payloads/reducers.ts index 422762b0..da135ac0 100644 --- a/libs/store/src/lib/payloads/reducers.ts +++ b/libs/store/src/lib/payloads/reducers.ts @@ -53,7 +53,7 @@ export type UpdateRaceReducerPayload = { export type UpdatePlayerLogReducerPayload = { playerLogId: AppPlayerLogId; - playerLog: AppPlayerLog; + playerLog: AppPlayerLog | AppPlayerLog[]; }; export type RemovePlayerReducerPayload = { diff --git a/libs/store/src/lib/reducers/update.spec.ts b/libs/store/src/lib/reducers/update.spec.ts index bb1fa0cc..fe6e2aeb 100644 --- a/libs/store/src/lib/reducers/update.spec.ts +++ b/libs/store/src/lib/reducers/update.spec.ts @@ -306,7 +306,7 @@ describe('[Reducers] Update operations', () => { }); describe('Update player logs', () => { - it('(id, not exisiting player log) => Add first player log to player logs array in player logs model', () => { + it('(id, not existing player log) => Add first player log to player logs array in player logs model', () => { const initialValues: AppStateModel = initialState; const store = initializeStore(initialValues); @@ -396,5 +396,81 @@ describe('[Reducers] Update operations', () => { game: expectedResult, }); }); + + it('(id) => Push new player logs (The logs received as an array) to existing player logs array in player logs model', () => { + const initialValues: AppStateModel = { + ...initialState, + playerLogsModel: { + ...initialState.playerLogsModel, + 'T:testTOUR-R:001-P:testPLAY': [ + { + timestamp: 1234567000, + textLength: 0, + }, + { + timestamp: 1234567002, + textLength: 8, + }, + { + timestamp: 1234567005, + textLength: 14, + }, + { + timestamp: 1234567008, + textLength: 20, + }, + ], + }, + }; + + const store = initializeStore(initialValues); + const initialStoreState = store.getState(); + + store.dispatch.game.updatePlayerLogReducer({ + playerLogId: 'T:testTOUR-R:001-P:testPLAY', + playerLog: [ + { + timestamp: 1234567010, + textLength: 26, + }, + { + timestamp: 1234567012, + textLength: 28, + }, + { + timestamp: 1234567014, + textLength: 30, + }, + ], + }); + + const expectedResult = { + ...initialValues, + playerLogsModel: { + ...initialValues.playerLogsModel, + 'T:testTOUR-R:001-P:testPLAY': [ + ...initialValues.playerLogsModel['T:testTOUR-R:001-P:testPLAY'], + { + timestamp: 1234567010, + textLength: 26, + }, + { + timestamp: 1234567012, + textLength: 28, + }, + { + timestamp: 1234567014, + textLength: 30, + }, + ], + }, + }; + + const storeState = store.getState(); + expect(storeState).toEqual({ + ...initialStoreState, + game: expectedResult, + }); + }); }); }); diff --git a/libs/store/src/lib/reducers/update.ts b/libs/store/src/lib/reducers/update.ts index e263067c..a1d71319 100644 --- a/libs/store/src/lib/reducers/update.ts +++ b/libs/store/src/lib/reducers/update.ts @@ -108,12 +108,18 @@ export const updatePlayerLogReducer = ( payload: UpdatePlayerLogReducerPayload, ): AppStateModel => { const { playerLogId, playerLog } = payload; + + const existingPlayerLogs = state.playerLogsModel[playerLogId] || []; + const updatedPlayerLogs = Array.isArray(playerLog) + ? [...existingPlayerLogs, ...playerLog] + : [...existingPlayerLogs, playerLog]; + /** State model after updating specific player log-in player logs model. */ const newState: AppStateModel = { ...state, playerLogsModel: { ...state.playerLogsModel, - [playerLogId]: [...(state.playerLogsModel[playerLogId] || []), playerLog], + [playerLogId]: updatedPlayerLogs, }, }; return newState; diff --git a/libs/util/src/lib/compute-race-duration.spec.ts b/libs/util/src/lib/compute-race-duration.spec.ts index ae12b3a5..f93f4eed 100644 --- a/libs/util/src/lib/compute-race-duration.spec.ts +++ b/libs/util/src/lib/compute-race-duration.spec.ts @@ -5,8 +5,8 @@ import { computeRaceDuration } from './compute-race-duration'; describe('[Utils] computeRaceDuration', () => { it.each([ - [M_RACE_TEXT0, 191], - [M_RACE_TEXT1, 162], + [M_RACE_TEXT0, 159], + [M_RACE_TEXT1, 135], ])('Calculate timeout timer', (text, time) => { const timeoutDuration = computeRaceDuration(text); expect(timeoutDuration).toEqual(time); diff --git a/libs/util/src/lib/compute-race-duration.ts b/libs/util/src/lib/compute-race-duration.ts index a85c8b34..dbf2b85f 100644 --- a/libs/util/src/lib/compute-race-duration.ts +++ b/libs/util/src/lib/compute-race-duration.ts @@ -8,12 +8,10 @@ import { AVERAGE_WPM } from '@razor/constants'; export const computeRaceDuration = (text: string): number => { /** Average word count * - * Assuming that the average word has 5 letters. + * Assuming that the average word has 5 letters (and with the space 6 characters). */ - const wordCount = text.length / 5; - + const wordCount = text.length / 6; const averageTime = Math.ceil((wordCount / AVERAGE_WPM) * 60); - const maximumAllowedTime = Math.ceil(averageTime * 1.5); return maximumAllowedTime;