-
- 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;