join game error dialog

This commit is contained in:
seavor 2026-04-20 00:25:10 -05:00
parent db1530c9e9
commit 2aeb1542b1
20 changed files with 415 additions and 18 deletions

View file

@ -65,6 +65,8 @@ export const disconnectedState: Partial<RootState> = {
sortUsersBy: { field: App.UserSortField.NAME, order: App.SortDirection.ASC },
selectedGameIds: {},
gameFilters: {},
joinGamePending: false,
joinGameError: null,
},
games: { games: {} },
action: { type: null, payload: null, meta: null, error: false, count: 0 },

View file

@ -48,4 +48,12 @@ export class RoomResponseImpl implements WebsocketTypes.IRoomResponse<WebsocketT
joinedGame(roomId: number, gameId: number): void {
RoomsDispatch.joinedGame(roomId, gameId);
}
setJoinGamePending(pending: boolean): void {
RoomsDispatch.setJoinGamePending(pending);
}
setJoinGameError(code: number, message: string): void {
RoomsDispatch.setJoinGameError(code, message);
}
}

View file

@ -55,6 +55,7 @@ function buildState(
room: ReturnType<typeof makeRoomEntry>,
user = makeUser(),
selectedGameId?: number,
roomsOverrides: Partial<{ joinGamePending: boolean; joinGameError: { code: number; message: string } | null }> = {},
) {
return makeStoreState({
...connectedWithRoomsState,
@ -67,6 +68,9 @@ function buildState(
sortUsersBy: { field: App.UserSortField.NAME, order: App.SortDirection.ASC },
selectedGameIds: selectedGameId != null ? { 1: selectedGameId } : {},
gameFilters: {},
joinGamePending: false,
joinGameError: null,
...roomsOverrides,
} as any,
server: {
...(connectedWithRoomsState.server as any),
@ -181,6 +185,41 @@ describe('GameSelector', () => {
expect(screen.getByRole('button', { name: /Join as Judge Spectator/i })).toBeInTheDocument();
});
it('renders AlertDialog with the join error message from state', () => {
mockUseWebClient.mockReturnValue(makeWebClient());
const room = makeRoomEntry([]);
renderWithProviders(<GameSelector room={room as any} />, {
preloadedState: buildState(room, makeUser(), undefined, {
joinGameError: { code: 10, message: 'The game is already full.' },
}),
});
expect(screen.getByText('Error')).toBeInTheDocument();
expect(screen.getByText('The game is already full.')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /^ok$/i })).toBeInTheDocument();
});
it('does not render AlertDialog when joinGameError is null (covers silent RespContextError)', () => {
mockUseWebClient.mockReturnValue(makeWebClient());
const room = makeRoomEntry([]);
renderWithProviders(<GameSelector room={room as any} />, {
preloadedState: buildState(room, makeUser(), undefined, { joinGameError: null }),
});
// Only the CreateGame / FilterGames / PromptDialog / AlertDialog dialogs might exist; none
// should be open, so no role="dialog" in the DOM.
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
it('Join button is disabled while joinGamePending is true even when a game is selected', () => {
mockUseWebClient.mockReturnValue(makeWebClient());
const game = makeGame({ gameId: 7 });
const room = makeRoomEntry([game]);
renderWithProviders(<GameSelector room={room as any} />, {
preloadedState: buildState(room, makeUser(), 7, { joinGamePending: true }),
});
expect(screen.getByRole('button', { name: /^Join$/ })).toBeDisabled();
expect(screen.getByRole('button', { name: /Join as Spectator/i })).toBeDisabled();
});
it('clicking Create then submitting forwards createGame', () => {
const client = makeWebClient();
mockUseWebClient.mockReturnValue(client);

View file

@ -5,7 +5,7 @@ import Typography from '@mui/material/Typography';
import { RoomsDispatch, RoomsSelectors, ServerSelectors, useAppSelector } from '@app/store';
import { useWebClient } from '@app/hooks';
import type { App, Enriched } from '@app/types';
import { CreateGameDialog, FilterGamesDialog, PromptDialog } from '@app/dialogs';
import { AlertDialog, CreateGameDialog, FilterGamesDialog, PromptDialog } from '@app/dialogs';
import OpenGames from '../OpenGames';
import GameSelectorToolbar from './GameSelectorToolbar';
@ -16,7 +16,7 @@ interface GameSelectorProps {
room: Enriched.Room;
}
interface PendingJoin {
interface PendingPasswordJoin {
gameId: number;
asSpectator: boolean;
asJudge: boolean;
@ -34,10 +34,12 @@ const GameSelector = ({ room }: GameSelectorProps) => {
const isFilterActive = useAppSelector((state) => RoomsSelectors.isGameFilterActive(state, roomId));
const filters = useAppSelector((state) => RoomsSelectors.getGameFilters(state, roomId));
const isJudgeUser = useAppSelector(ServerSelectors.getIsUserJudge);
const joinPending = useAppSelector(RoomsSelectors.getJoinGamePending);
const joinError = useAppSelector(RoomsSelectors.getJoinGameError);
const [createOpen, setCreateOpen] = useState(false);
const [filterOpen, setFilterOpen] = useState(false);
const [pendingJoin, setPendingJoin] = useState<PendingJoin | null>(null);
const [pendingPasswordJoin, setPendingPasswordJoin] = useState<PendingPasswordJoin | null>(null);
const sendJoin = useCallback(
(gameId: number, asSpectator: boolean, asJudge: boolean, password: string) => {
@ -65,7 +67,7 @@ const GameSelector = ({ room }: GameSelectorProps) => {
const needsPassword =
info.withPassword && !(effectiveSpectator && !info.spectatorsNeedPassword);
if (needsPassword) {
setPendingJoin({ gameId: info.gameId, asSpectator: effectiveSpectator, asJudge });
setPendingPasswordJoin({ gameId: info.gameId, asSpectator: effectiveSpectator, asJudge });
return;
}
sendJoin(info.gameId, effectiveSpectator, asJudge, '');
@ -80,8 +82,9 @@ const GameSelector = ({ room }: GameSelectorProps) => {
[beginJoin],
);
const canJoin = Boolean(selectedGame && selectedGame.info.playerCount < selectedGame.info.maxPlayers);
const canSpectate = Boolean(selectedGame && selectedGame.info.spectatorsAllowed);
const canJoin =
Boolean(selectedGame && selectedGame.info.playerCount < selectedGame.info.maxPlayers) && !joinPending;
const canSpectate = Boolean(selectedGame && selectedGame.info.spectatorsAllowed) && !joinPending;
const handleCreateSubmit = (params: App.CreateGameParams) => {
webClient.request.rooms.createGame(roomId, params);
@ -94,11 +97,11 @@ const GameSelector = ({ room }: GameSelectorProps) => {
};
const handlePasswordSubmit = (password: string) => {
if (!pendingJoin) {
if (!pendingPasswordJoin) {
return;
}
sendJoin(pendingJoin.gameId, pendingJoin.asSpectator, pendingJoin.asJudge, password);
setPendingJoin(null);
sendJoin(pendingPasswordJoin.gameId, pendingPasswordJoin.asSpectator, pendingPasswordJoin.asJudge, password);
setPendingPasswordJoin(null);
};
return (
@ -138,12 +141,18 @@ const GameSelector = ({ room }: GameSelectorProps) => {
onSubmit={handleFilterSubmit}
/>
<PromptDialog
isOpen={pendingJoin !== null}
isOpen={pendingPasswordJoin !== null}
title="Password required"
label="Password"
submitLabel="Join"
onSubmit={handlePasswordSubmit}
onCancel={() => setPendingJoin(null)}
onCancel={() => setPendingPasswordJoin(null)}
/>
<AlertDialog
isOpen={joinError !== null}
title="Error"
message={joinError?.message ?? ''}
onDismiss={() => RoomsDispatch.clearJoinGameError()}
/>
</Paper>
);

View file

@ -0,0 +1,3 @@
.alert-dialog__body {
min-width: 320px;
}

View file

@ -0,0 +1,76 @@
import { screen, fireEvent } from '@testing-library/react';
import { renderWithProviders } from '../../__test-utils__';
import AlertDialog from './AlertDialog';
describe('AlertDialog', () => {
it('renders the title, message, and default OK button', () => {
renderWithProviders(
<AlertDialog
isOpen
title="Error"
message="The game is already full."
onDismiss={() => {}}
/>,
);
expect(screen.getByText('Error')).toBeInTheDocument();
expect(screen.getByText('The game is already full.')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /^ok$/i })).toBeInTheDocument();
});
it('uses a custom buttonLabel when provided', () => {
renderWithProviders(
<AlertDialog
isOpen
title="T"
message="M"
buttonLabel="Dismiss"
onDismiss={() => {}}
/>,
);
expect(screen.getByRole('button', { name: /dismiss/i })).toBeInTheDocument();
});
it('fires onDismiss when the OK button is clicked', () => {
const onDismiss = vi.fn();
renderWithProviders(
<AlertDialog
isOpen
title="T"
message="M"
onDismiss={onDismiss}
/>,
);
fireEvent.click(screen.getByRole('button', { name: /^ok$/i }));
expect(onDismiss).toHaveBeenCalled();
});
it('fires onDismiss on Escape key', () => {
const onDismiss = vi.fn();
renderWithProviders(
<AlertDialog
isOpen
title="T"
message="M"
onDismiss={onDismiss}
/>,
);
fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape', code: 'Escape' });
expect(onDismiss).toHaveBeenCalled();
});
it('does not render when closed', () => {
renderWithProviders(
<AlertDialog
isOpen={false}
title="T"
message="M"
onDismiss={() => {}}
/>,
);
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
});

View file

@ -0,0 +1,80 @@
import { styled } from '@mui/material/styles';
import Dialog from '@mui/material/Dialog';
import DialogActions from '@mui/material/DialogActions';
import DialogContent from '@mui/material/DialogContent';
import DialogContentText from '@mui/material/DialogContentText';
import DialogTitle from '@mui/material/DialogTitle';
import Typography from '@mui/material/Typography';
import Button from '@mui/material/Button';
import './AlertDialog.css';
const PREFIX = 'AlertDialog';
const classes = {
root: `${PREFIX}-root`,
};
const StyledDialog = styled(Dialog)(({ theme }) => ({
[`&.${classes.root}`]: {
'& .dialog-title__wrapper': {
borderColor: theme.palette.grey[300],
},
},
}));
export type AlertDialogSeverity = 'error' | 'info';
export interface AlertDialogProps {
isOpen: boolean;
title: string;
message: string;
buttonLabel?: string;
severity?: AlertDialogSeverity;
onDismiss: () => void;
}
/**
* Single-button modal alert. Mirrors desktop's QMessageBox::critical pattern
* (see cockatrice/src/interface/widgets/server/game_selector.cpp:234-260 for
* the join-game error dialogs this was originally built for).
*/
function AlertDialog({
isOpen,
title,
message,
buttonLabel = 'OK',
severity = 'error',
onDismiss,
}: AlertDialogProps) {
return (
<StyledDialog
className={'AlertDialog ' + classes.root}
open={isOpen}
onClose={onDismiss}
maxWidth={false}
>
<DialogTitle className="dialog-title">
<div className="dialog-title__wrapper">
<Typography variant="h2">{title}</Typography>
</div>
</DialogTitle>
<DialogContent className="dialog-content alert-dialog__body">
<DialogContentText>{message}</DialogContentText>
</DialogContent>
<DialogActions>
<Button
type="button"
variant="contained"
color={severity === 'error' ? 'error' : 'primary'}
onClick={onDismiss}
autoFocus
>
{buttonLabel}
</Button>
</DialogActions>
</StyledDialog>
);
}
export default AlertDialog;

View file

@ -1,4 +1,6 @@
export { default as AccountActivationDialog } from './AccountActivationDialog/AccountActivationDialog';
export { default as AlertDialog } from './AlertDialog/AlertDialog';
export type { AlertDialogProps, AlertDialogSeverity } from './AlertDialog/AlertDialog';
export { default as CardImportDialog } from './CardImportDialog/CardImportDialog';
export { default as ConfirmDialog } from './ConfirmDialog/ConfirmDialog';
export { default as CreateCounterDialog } from './CreateCounterDialog/CreateCounterDialog';

View file

@ -107,6 +107,8 @@ export function makeRoomsState(overrides: Partial<RoomsState> = {}): RoomsState
},
selectedGameIds: {},
gameFilters: {},
joinGamePending: false,
joinGameError: null,
...overrides,
};
}

View file

@ -109,4 +109,21 @@ describe('Dispatch', () => {
Dispatch.clearGameFilters(1);
expect(mockDispatch).toHaveBeenCalledWith(Actions.clearGameFilters({ roomId: 1 }));
});
it('setJoinGamePending dispatches Actions.setJoinGamePending()', () => {
Dispatch.setJoinGamePending(true);
expect(mockDispatch).toHaveBeenCalledWith(Actions.setJoinGamePending({ pending: true }));
});
it('setJoinGameError dispatches Actions.setJoinGameError()', () => {
Dispatch.setJoinGameError(10, 'The game is already full.');
expect(mockDispatch).toHaveBeenCalledWith(
Actions.setJoinGameError({ code: 10, message: 'The game is already full.' })
);
});
it('clearJoinGameError dispatches Actions.clearJoinGameError()', () => {
Dispatch.clearJoinGameError();
expect(mockDispatch).toHaveBeenCalledWith(Actions.clearJoinGameError());
});
});

View file

@ -64,4 +64,16 @@ export const Dispatch = {
clearGameFilters: (roomId: number) => {
store.dispatch(Actions.clearGameFilters({ roomId }));
},
setJoinGamePending: (pending: boolean) => {
store.dispatch(Actions.setJoinGamePending({ pending }));
},
setJoinGameError: (code: number, message: string) => {
store.dispatch(Actions.setJoinGameError({ code, message }));
},
clearJoinGameError: () => {
store.dispatch(Actions.clearJoinGameError());
},
}

View file

@ -9,6 +9,13 @@ export interface RoomsState {
sortUsersBy: RoomsStateSortUsersBy;
selectedGameIds: SelectedGameIds;
gameFilters: RoomsStateGameFilters;
joinGamePending: boolean;
joinGameError: JoinGameError | null;
}
export interface JoinGameError {
code: number;
message: string;
}
export interface RoomsStateRooms {

View file

@ -401,6 +401,38 @@ describe('LEAVE_ROOM \u2014 selection and filters', () => {
});
describe('JoinGame error state', () => {
it('SET_JOIN_GAME_PENDING toggles joinGamePending', () => {
const state = makeRoomsState({ joinGamePending: false });
const result = roomsReducer(state, Actions.setJoinGamePending({ pending: true }));
expect(result.joinGamePending).toBe(true);
});
it('SET_JOIN_GAME_ERROR sets the error and clears joinGamePending', () => {
const state = makeRoomsState({ joinGamePending: true });
const result = roomsReducer(state, Actions.setJoinGameError({ code: 10, message: 'The game is already full.' }));
expect(result.joinGameError).toEqual({ code: 10, message: 'The game is already full.' });
expect(result.joinGamePending).toBe(false);
});
it('CLEAR_JOIN_GAME_ERROR nulls the error', () => {
const state = makeRoomsState({ joinGameError: { code: 10, message: 'The game is already full.' } });
const result = roomsReducer(state, Actions.clearJoinGameError());
expect(result.joinGameError).toBeNull();
});
it('CLEAR_STORE resets joinGame error state', () => {
const state = makeRoomsState({
joinGamePending: true,
joinGameError: { code: 12, message: 'Wrong password.' },
});
const result = roomsReducer(state, Actions.clearStore());
expect(result.joinGamePending).toBe(false);
expect(result.joinGameError).toBeNull();
});
});
describe('SET_GAME_FILTERS / CLEAR_GAME_FILTERS', () => {
it('SET_GAME_FILTERS stores filter state for the room', () => {
const state = makeRoomsState();

View file

@ -23,6 +23,8 @@ const initialState: RoomsState = {
},
selectedGameIds: {},
gameFilters: {},
joinGamePending: false,
joinGameError: null,
};
export const roomsSlice = createSlice({
@ -205,6 +207,19 @@ export const roomsSlice = createSlice({
const { roomId } = action.payload;
state.gameFilters[roomId] = { ...DEFAULT_GAME_FILTERS };
},
setJoinGamePending: (state, action: PayloadAction<{ pending: boolean }>) => {
state.joinGamePending = action.payload.pending;
},
setJoinGameError: (state, action: PayloadAction<{ code: number; message: string }>) => {
state.joinGameError = { code: action.payload.code, message: action.payload.message };
state.joinGamePending = false;
},
clearJoinGameError: (state) => {
state.joinGameError = null;
},
},
});

View file

@ -109,6 +109,9 @@ export const Selectors = {
return !isGameFiltersAtDefaults(filters);
},
getJoinGamePending: ({ rooms }: State) => rooms.joinGamePending,
getJoinGameError: ({ rooms }: State) => rooms.joinGameError,
/**
* Sorted + filter-applied view of a room's games for display. Filters
* mirror desktop GamesProxyModel; buddy/ignore checks read from server.

View file

@ -18,6 +18,9 @@ export const Types = {
SELECT_GAME: a.selectGame.type,
SET_GAME_FILTERS: a.setGameFilters.type,
CLEAR_GAME_FILTERS: a.clearGameFilters.type,
SET_JOIN_GAME_PENDING: a.setJoinGamePending.type,
SET_JOIN_GAME_ERROR: a.setJoinGameError.type,
CLEAR_JOIN_GAME_ERROR: a.clearJoinGameError.type,
} as const;
export { MAX_ROOM_MESSAGES } from './rooms.reducer';

View file

@ -89,6 +89,8 @@ const room = {
removeMessages: vi.fn(),
gameCreated: vi.fn(),
joinedGame: vi.fn(),
setJoinGamePending: vi.fn(),
setJoinGameError: vi.fn(),
};
const game = {

View file

@ -1,13 +1,47 @@
import { create } from '@bufbuild/protobuf';
import { WebClient } from '../../WebClient';
import { Command_JoinGame_ext, Command_JoinGameSchema } from '@app/generated';
import { Command_JoinGame_ext, Command_JoinGameSchema, Response_ResponseCode } from '@app/generated';
import type { JoinGameParams } from '@app/generated';
// Desktop message strings from cockatrice/src/interface/widgets/server/game_selector.cpp:234-260
// (GameSelector::checkResponse). Codes not listed here (e.g. RespContextError) intentionally
// fall through to the desktop's `default:;` — silent to the user.
const ERROR_MESSAGES: Record<number, string> = {
[Response_ResponseCode.RespNotInRoom]: 'Please join the appropriate room first.',
[Response_ResponseCode.RespNameNotFound]: 'The game does not exist any more.',
[Response_ResponseCode.RespGameFull]: 'The game is already full.',
[Response_ResponseCode.RespWrongPassword]: 'Wrong password.',
[Response_ResponseCode.RespSpectatorsNotAllowed]: 'Spectators are not allowed in this game.',
[Response_ResponseCode.RespOnlyBuddies]: "This game is only open to its creator's buddies.",
[Response_ResponseCode.RespUserLevelTooLow]: 'This game is only open to registered users.',
[Response_ResponseCode.RespInIgnoreList]: 'You are being ignored by the creator of this game.',
};
export function joinGame(roomId: number, joinGameParams: JoinGameParams): void {
WebClient.instance.protobuf.sendRoomCommand(roomId, Command_JoinGame_ext, create(Command_JoinGameSchema, joinGameParams), {
onSuccess: () => {
WebClient.instance.response.room.joinedGame(roomId, joinGameParams.gameId);
const response = WebClient.instance.response.room;
response.setJoinGamePending(true);
const onResponseCode: { [code: number]: () => void } = {
// Match desktop default:; — acknowledge silently, no user dialog.
[Response_ResponseCode.RespContextError]: () => response.setJoinGamePending(false),
};
for (const codeStr of Object.keys(ERROR_MESSAGES)) {
const code = Number(codeStr);
onResponseCode[code] = () => response.setJoinGameError(code, ERROR_MESSAGES[code]);
}
WebClient.instance.protobuf.sendRoomCommand(
roomId,
Command_JoinGame_ext,
create(Command_JoinGameSchema, joinGameParams),
{
onSuccess: () => {
response.setJoinGamePending(false);
response.joinedGame(roomId, joinGameParams.gameId);
},
onResponseCode,
onError: () => response.setJoinGamePending(false),
},
});
);
}

View file

@ -9,6 +9,7 @@ import {
Command_JoinGameSchema,
Command_LeaveRoom_ext,
Command_RoomSay_ext,
Response_ResponseCode,
} from '@app/generated';
import { createGame } from './createGame';
@ -18,7 +19,7 @@ import { roomSay } from './roomSay';
import { create } from '@bufbuild/protobuf';
import { Mock } from 'vitest';
const { invokeOnSuccess } = makeCallbackHelpers(
const { invokeOnSuccess, invokeResponseCode, invokeOnError } = makeCallbackHelpers(
WebClient.instance.protobuf.sendRoomCommand as Mock,
// sendRoomCommand(roomId, ext, value, options) — options at index 3
3
@ -41,6 +42,11 @@ describe('createGame', () => {
});
describe('joinGame', () => {
beforeEach(() => {
(WebClient.instance.response.room.joinedGame as Mock).mockClear();
(WebClient.instance.response.room.setJoinGamePending as Mock).mockClear();
(WebClient.instance.response.room.setJoinGameError as Mock).mockClear();
});
it('calls sendRoomCommand with Command_JoinGame', () => {
joinGame(7, create(Command_JoinGameSchema, { gameId: 42, password: '' }));
@ -49,11 +55,54 @@ describe('joinGame', () => {
);
});
it('onSuccess calls response.room.joinedGame with roomId and gameId', () => {
it('dispatches setJoinGamePending(true) before sending', () => {
joinGame(7, create(Command_JoinGameSchema, { gameId: 42 }));
expect(WebClient.instance.response.room.setJoinGamePending).toHaveBeenCalledWith(true);
});
it('onSuccess clears pending and calls response.room.joinedGame with roomId and gameId', () => {
joinGame(7, create(Command_JoinGameSchema, { gameId: 42 }));
invokeOnSuccess();
expect(WebClient.instance.response.room.setJoinGamePending).toHaveBeenLastCalledWith(false);
expect(WebClient.instance.response.room.joinedGame).toHaveBeenCalledWith(7, 42);
});
// Desktop GameSelector::checkResponse — matching message strings from
// cockatrice/src/interface/widgets/server/game_selector.cpp:234-260.
const errorCases: Array<[number, string]> = [
[Response_ResponseCode.RespNotInRoom, 'Please join the appropriate room first.'],
[Response_ResponseCode.RespNameNotFound, 'The game does not exist any more.'],
[Response_ResponseCode.RespGameFull, 'The game is already full.'],
[Response_ResponseCode.RespWrongPassword, 'Wrong password.'],
[Response_ResponseCode.RespSpectatorsNotAllowed, 'Spectators are not allowed in this game.'],
[Response_ResponseCode.RespOnlyBuddies, "This game is only open to its creator's buddies."],
[Response_ResponseCode.RespUserLevelTooLow, 'This game is only open to registered users.'],
[Response_ResponseCode.RespInIgnoreList, 'You are being ignored by the creator of this game.'],
];
it.each(errorCases)('code %i dispatches setJoinGameError with desktop-matching message', (code, message) => {
joinGame(7, create(Command_JoinGameSchema, { gameId: 42 }));
invokeResponseCode(code);
expect(WebClient.instance.response.room.setJoinGameError).toHaveBeenCalledWith(code, message);
expect(WebClient.instance.response.room.joinedGame).not.toHaveBeenCalled();
});
it('code 11 (RespContextError) is silent — clears pending, no setJoinGameError, no console.error', () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
joinGame(7, create(Command_JoinGameSchema, { gameId: 42 }));
invokeResponseCode(Response_ResponseCode.RespContextError);
expect(WebClient.instance.response.room.setJoinGameError).not.toHaveBeenCalled();
expect(WebClient.instance.response.room.setJoinGamePending).toHaveBeenLastCalledWith(false);
expect(consoleError).not.toHaveBeenCalled();
consoleError.mockRestore();
});
it('unknown response code goes to onError — clears pending, no setJoinGameError', () => {
joinGame(7, create(Command_JoinGameSchema, { gameId: 42 }));
invokeOnError(99);
expect(WebClient.instance.response.room.setJoinGameError).not.toHaveBeenCalled();
expect(WebClient.instance.response.room.setJoinGamePending).toHaveBeenLastCalledWith(false);
});
});
describe('leaveRoom', () => {

View file

@ -120,6 +120,8 @@ export interface IRoomResponse<T extends RoomEventMap = WebSocketRoomResponseOve
removeMessages(roomId: number, name: string, amount: number): void;
gameCreated(roomId: number): void;
joinedGame(roomId: number, gameId: number): void;
setJoinGamePending(pending: boolean): void;
setJoinGameError(code: number, message: string): void;
}
export interface IGameResponse {