more integration tests

This commit is contained in:
seavor 2026-04-16 12:40:47 -05:00
parent 4b5f66d497
commit decebc25c7
192 changed files with 3090 additions and 1657 deletions

View file

@ -11,7 +11,8 @@ import { createEcmaScriptPlugin, runNodeJs } from '@bufbuild/protoplugin';
const HEADER = [ const HEADER = [
'// @generated by protoc-gen-data. DO NOT EDIT.', '// @generated by protoc-gen-data. DO NOT EDIT.',
'// Rollup of all proto modules + MessageInitShape param aliases for every Command_*.', '// Rollup of all proto modules + MessageInitShape param aliases for every Command_*,',
'// plus type maps for Response/Event extensions grouped by scope.',
'/* eslint-disable */', '/* eslint-disable */',
'', '',
'', '',
@ -55,6 +56,71 @@ const inner = createEcmaScriptPlugin({
} }
f.print(); f.print();
// ── Type maps for Response/Event extensions, grouped by extendee ────────
//
// Scans all messages for nested `extend` declarations and groups them by
// which message they extend (Response, SessionEvent, RoomEvent, GameEvent).
// Emits one `interface *Map { TypeName: TypeName; ... }` per scope.
/** @type {Map<string, import('@bufbuild/protobuf').DescMessage[]>} */
const extendeeGroups = new Map();
for (const file of sortedFiles) {
for (const msg of file.messages) {
for (const ext of msg.nestedExtensions) {
const target = ext.extendee.name;
const group = extendeeGroups.get(target);
if (group) {
group.push(msg);
} else {
extendeeGroups.set(target, [msg]);
}
}
}
}
/** @type {[string, string, import('@bufbuild/protobuf').DescMessage | null][]} */
const maps = [
['ResponseMap', 'Response', null],
['SessionEventMap', 'SessionEvent', null],
['RoomEventMap', 'RoomEvent', null],
['GameEventMap', 'GameEvent', null],
];
// Resolve the base extendee message for maps that need the base type included
for (const file of sortedFiles) {
for (const msg of file.messages) {
for (const entry of maps) {
if (msg.name === entry[1]) {
entry[2] = msg;
}
}
}
}
for (const [mapName, extendeeName, baseMsg] of maps) {
const msgs = (extendeeGroups.get(extendeeName) || []).slice();
msgs.sort((a, b) => a.name.localeCompare(b.name));
if (msgs.length === 0 && !baseMsg) continue;
f.print('export interface ', mapName, ' {');
// Include the base extendee type itself (e.g. Response in ResponseMap)
if (baseMsg) {
const sym = f.import(baseMsg.name, `./proto/${baseMsg.file.name}_pb`, true);
f.print(' ', baseMsg.name, ': ', sym, ';');
}
for (const msg of msgs) {
const sym = f.import(msg.name, `./proto/${msg.file.name}_pb`, true);
f.print(' ', msg.name, ': ', sym, ';');
}
f.print('}');
f.print();
}
// Generic extension registry infrastructure. Consolidates the three // Generic extension registry infrastructure. Consolidates the three
// near-duplicate registry types and helpers that used to live in // near-duplicate registry types and helpers that used to live in
// src/websocket/services/protobuf-types.ts into one generic pair. // src/websocket/services/protobuf-types.ts into one generic pair.

View file

@ -19,9 +19,9 @@ const types = (...types) => types.map((type) => ({ to: { type } }));
const rules = [ const rules = [
{ from: { type: 'generated' }, allow: [] }, { from: { type: 'generated' }, allow: [] },
{ from: { type: 'types' }, allow: types('generated') }, { from: { type: 'types' }, allow: types('generated', 'websocket') },
{ from: { type: 'websocket' }, allow: types('types') }, { from: { type: 'websocket' }, allow: types('generated') },
{ from: { type: 'store' }, allow: types('types') }, { from: { type: 'store' }, allow: types('types') },
{ from: { type: 'api' }, allow: types('types', 'store', 'websocket') }, { from: { type: 'api' }, allow: types('types', 'store', 'websocket') },
@ -35,21 +35,27 @@ const rules = [
{ from: { type: 'forms' }, allow: types('components', 'hooks', 'types', 'services', 'store') }, { from: { type: 'forms' }, allow: types('components', 'hooks', 'types', 'services', 'store') },
]; ];
export const boundariesConfig = { export const boundariesConfig = [
plugins: { boundaries }, {
settings: { plugins: { boundaries },
'boundaries/elements': elements, settings: {
'import/resolver': { 'boundaries/elements': elements,
'import/resolver': {
typescript: { typescript: {
alwaysTryTypes: true, alwaysTryTypes: true,
project: './tsconfig.json', project: './tsconfig.json',
}, },
},
},
rules: {
'boundaries/dependencies': ['error', {
default: 'disallow',
rules,
}],
}, },
}, },
rules: { {
'boundaries/dependencies': ['error', { files: ['**/*.spec.*'],
default: 'disallow', rules: { 'boundaries/dependencies': 'off' },
rules,
}],
}, },
}; ];

View file

@ -14,7 +14,7 @@ export default tseslint.config(
...tseslint.configs.recommended, ...tseslint.configs.recommended,
// Enforce module boundaries // Enforce module boundaries
boundariesConfig, ...boundariesConfig,
// Project-specific config // Project-specific config
{ {

View file

@ -0,0 +1,66 @@
// Admin command pipeline smoke test — validates that sendAdminCommand
// encodes, correlates, and persists correctly end-to-end.
import { create } from '@bufbuild/protobuf';
import { describe, expect, it } from 'vitest';
import { Data } from '@app/types';
import { store } from '@app/store';
import { AdminCommands } from '@app/websocket';
import { connectAndLogin } from './helpers/setup';
import {
buildResponse,
buildResponseMessage,
buildSessionEventMessage,
deliverMessage,
} from './helpers/protobuf-builders';
import { findLastAdminCommand } from './helpers/command-capture';
describe('admin commands', () => {
it('adjustMod modifies the user level bitflags on success', () => {
connectAndLogin();
// Add bob to the user list so the reducer has a target
deliverMessage(buildSessionEventMessage(
Data.Event_UserJoined_ext,
create(Data.Event_UserJoinedSchema, {
userInfo: create(Data.ServerInfo_UserSchema, {
name: 'bob',
userLevel: Data.ServerInfo_User_UserLevelFlag.IsRegistered,
}),
})
));
expect(store.getState().server.users.bob).toBeDefined();
AdminCommands.adjustMod('bob', true, false);
const { cmdId, value } = findLastAdminCommand(Data.Command_AdjustMod_ext);
expect(value.userName).toBe('bob');
expect(value.shouldBeMod).toBe(true);
expect(value.shouldBeJudge).toBe(false);
deliverMessage(buildResponseMessage(buildResponse({
cmdId,
responseCode: Data.Response_ResponseCode.RespOk,
})));
const bobLevel = store.getState().server.users.bob.userLevel;
expect(bobLevel & Data.ServerInfo_User_UserLevelFlag.IsModerator).toBeTruthy();
});
it('shutdownServer sends command and dispatches on success', () => {
connectAndLogin();
AdminCommands.shutdownServer('Scheduled maintenance', 10);
const { cmdId, value } = findLastAdminCommand(Data.Command_ShutdownServer_ext);
expect(value.reason).toBe('Scheduled maintenance');
expect(value.minutes).toBe(10);
deliverMessage(buildResponseMessage(buildResponse({
cmdId,
responseCode: Data.Response_ResponseCode.RespOk,
})));
});
});

View file

@ -1,12 +1,14 @@
// Authentication scenarios — login success/failure, register, and activate. // Authentication scenarios — login success/failure, register, activate,
// and the hashed-password (salt) login path.
import { create } from '@bufbuild/protobuf'; import { create } from '@bufbuild/protobuf';
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { App, Data } from '@app/types'; import { Data } from '@app/types';
import { store } from '@app/store'; import { store } from '@app/store';
import { StatusEnum, WebSocketConnectReason } from '@app/websocket';
import { connectAndHandshake } from './helpers/setup'; import { connectAndHandshake, connectAndHandshakeWithSalt } from './helpers/setup';
import { import {
buildResponse, buildResponse,
buildResponseMessage, buildResponseMessage,
@ -42,7 +44,7 @@ describe('authentication', () => {
}))); })));
const state = store.getState().server; const state = store.getState().server;
expect(state.status.state).toBe(App.StatusEnum.LOGGED_IN); expect(state.status.state).toBe(StatusEnum.LOGGED_IN);
expect(state.status.description).toBe('Logged in.'); expect(state.status.description).toBe('Logged in.');
expect(state.user?.name).toBe('alice'); expect(state.user?.name).toBe('alice');
expect(Object.keys(state.buddyList)).toEqual(['bob']); expect(Object.keys(state.buddyList)).toEqual(['bob']);
@ -62,7 +64,7 @@ describe('authentication', () => {
}))); })));
const state = store.getState().server; const state = store.getState().server;
expect(state.status.state).toBe(App.StatusEnum.DISCONNECTED); expect(state.status.state).toBe(StatusEnum.DISCONNECTED);
expect(state.user).toBeNull(); expect(state.user).toBeNull();
expect(state.buddyList).toEqual({}); expect(state.buddyList).toEqual({});
}); });
@ -70,7 +72,7 @@ describe('authentication', () => {
describe('register', () => { describe('register', () => {
const registerOptions = { const registerOptions = {
reason: App.WebSocketConnectReason.REGISTER, reason: WebSocketConnectReason.REGISTER as const,
host: 'localhost', host: 'localhost',
port: '4748', port: '4748',
userName: 'newbie', userName: 'newbie',
@ -78,10 +80,10 @@ describe('authentication', () => {
email: 'newbie@example.com', email: 'newbie@example.com',
country: 'US', country: 'US',
realName: 'New Bie', realName: 'New Bie',
} as const; };
it('auto-logs-in on RespRegistrationAccepted', () => { it('auto-logs-in on RespRegistrationAccepted', () => {
connectAndHandshake(registerOptions as any); connectAndHandshake(registerOptions);
const register = findLastSessionCommand(Data.Command_Register_ext); const register = findLastSessionCommand(Data.Command_Register_ext);
expect(register.value.userName).toBe('newbie'); expect(register.value.userName).toBe('newbie');
@ -97,7 +99,7 @@ describe('authentication', () => {
}); });
it('parks registration in awaiting-activation on RespRegistrationAcceptedNeedsActivation', () => { it('parks registration in awaiting-activation on RespRegistrationAcceptedNeedsActivation', () => {
connectAndHandshake(registerOptions as any); connectAndHandshake(registerOptions);
const register = findLastSessionCommand(Data.Command_Register_ext); const register = findLastSessionCommand(Data.Command_Register_ext);
deliverMessage(buildResponseMessage(buildResponse({ deliverMessage(buildResponseMessage(buildResponse({
@ -105,7 +107,7 @@ describe('authentication', () => {
responseCode: Data.Response_ResponseCode.RespRegistrationAcceptedNeedsActivation, responseCode: Data.Response_ResponseCode.RespRegistrationAcceptedNeedsActivation,
}))); })));
expect(store.getState().server.status.state).toBe(App.StatusEnum.DISCONNECTED); expect(store.getState().server.status.state).toBe(StatusEnum.DISCONNECTED);
expect(() => findLastSessionCommand(Data.Command_Login_ext)).toThrow(); expect(() => findLastSessionCommand(Data.Command_Login_ext)).toThrow();
}); });
}); });
@ -113,13 +115,13 @@ describe('authentication', () => {
describe('activate', () => { describe('activate', () => {
it('auto-logs-in on RespActivationAccepted', () => { it('auto-logs-in on RespActivationAccepted', () => {
connectAndHandshake({ connectAndHandshake({
reason: App.WebSocketConnectReason.ACTIVATE_ACCOUNT, reason: WebSocketConnectReason.ACTIVATE_ACCOUNT as const,
host: 'localhost', host: 'localhost',
port: '4748', port: '4748',
userName: 'alice', userName: 'alice',
token: 'abc-123', token: 'abc-123',
password: 'secret', password: 'secret',
} as any); });
const activate = findLastSessionCommand(Data.Command_Activate_ext); const activate = findLastSessionCommand(Data.Command_Activate_ext);
expect(activate.value.userName).toBe('alice'); expect(activate.value.userName).toBe('alice');
@ -133,4 +135,43 @@ describe('authentication', () => {
expect(login.value.userName).toBe('alice'); expect(login.value.userName).toBe('alice');
}); });
}); });
describe('hashed-password login (salt path)', () => {
it('requests salt then sends login with hashedPassword instead of plaintext', () => {
connectAndHandshakeWithSalt({ userName: 'alice', password: 'secret' });
// First command should be RequestPasswordSalt, not Login
const salt = findLastSessionCommand(Data.Command_RequestPasswordSalt_ext);
expect(salt.value.userName).toBe('alice');
expect(() => findLastSessionCommand(Data.Command_Login_ext)).toThrow();
// Deliver salt response
deliverMessage(buildResponseMessage(buildResponse({
cmdId: salt.cmdId,
responseCode: Data.Response_ResponseCode.RespOk,
ext: Data.Response_PasswordSalt_ext,
value: create(Data.Response_PasswordSaltSchema, { passwordSalt: 'test-salt-value' }),
})));
// Now login should have been sent with hashedPassword
const login = findLastSessionCommand(Data.Command_Login_ext);
expect(login.value.userName).toBe('alice');
expect(login.value.hashedPassword).toBeTruthy();
expect(login.value.password).toBeFalsy();
// Complete login
deliverMessage(buildResponseMessage(buildResponse({
cmdId: login.cmdId,
responseCode: Data.Response_ResponseCode.RespOk,
ext: Data.Response_Login_ext,
value: create(Data.Response_LoginSchema, {
userInfo: makeUser('alice'),
buddyList: [],
ignoreList: [],
}),
})));
expect(store.getState().server.status.state).toBe(StatusEnum.LOGGED_IN);
});
});
}); });

View file

@ -1,30 +1,44 @@
// Connection-lifecycle scenarios. Exercises the full transport handshake // Connection-lifecycle scenarios. Exercises the full transport handshake
// from `webClient.connect()` through `onopen`, ServerIdentification, and // from webClient.connect() through onopen, ServerIdentification, and
// disconnect — with only the browser WebSocket constructor mocked. // disconnect — with only the browser WebSocket constructor mocked.
import { create } from '@bufbuild/protobuf'; import { create } from '@bufbuild/protobuf';
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { App, Data } from '@app/types'; import { Data } from '@app/types';
import { store } from '@app/store'; import { store } from '@app/store';
import { StatusEnum } from '@app/websocket';
import { PROTOCOL_VERSION } from '../../src/websocket/config'; import { PROTOCOL_VERSION } from '../../src/websocket/config';
import { getMockWebSocket, getWebClient, openMockWebSocket } from './helpers/setup'; import {
getMockWebSocket,
getWebClient,
openMockWebSocket,
setPendingOptions,
connectAndHandshake,
} from './helpers/setup';
import type { WebSocketConnectOptions } from '@app/websocket';
import { WebSocketConnectReason } from '@app/websocket';
import { import {
buildSessionEventMessage, buildSessionEventMessage,
deliverMessage, deliverMessage,
} from './helpers/protobuf-builders'; } from './helpers/protobuf-builders';
import { findLastSessionCommand } from './helpers/command-capture'; import { findLastSessionCommand } from './helpers/command-capture';
function loginOptions(overrides: Partial<{ userName: string; password: string }> = {}) { function loginOptions(overrides: Partial<{ userName: string; password: string }> = {}): WebSocketConnectOptions {
return { return {
reason: App.WebSocketConnectReason.LOGIN, reason: WebSocketConnectReason.LOGIN,
host: 'localhost', host: 'localhost',
port: '4748', port: '4748',
userName: overrides.userName ?? 'alice', userName: overrides.userName ?? 'alice',
password: overrides.password ?? 'secret', password: overrides.password ?? 'secret',
} as const; };
}
function connectWithOptions(opts: WebSocketConnectOptions): void {
setPendingOptions(opts);
getWebClient().connect({ host: opts.host, port: opts.port });
} }
function serverIdentification( function serverIdentification(
@ -43,47 +57,45 @@ function serverIdentification(
describe('connection lifecycle', () => { describe('connection lifecycle', () => {
it('flips status through CONNECTING → CONNECTED on socket open', () => { it('flips status through CONNECTING → CONNECTED on socket open', () => {
getWebClient().connect(loginOptions()); connectWithOptions(loginOptions());
expect(store.getState().server.status.connectionAttemptMade).toBe(true); expect(store.getState().server.status.connectionAttemptMade).toBe(true);
openMockWebSocket(); openMockWebSocket();
expect(store.getState().server.status.state).toBe(App.StatusEnum.CONNECTED); expect(store.getState().server.status.state).toBe(StatusEnum.CONNECTED);
expect(store.getState().server.status.description).toBe('Connected'); expect(store.getState().server.status.description).toBe('Connected');
}); });
it('routes a matching ServerIdentification into LOGGING_IN and sends Command_Login', () => { it('routes a matching ServerIdentification into LOGGING_IN and sends Command_Login', () => {
getWebClient().connect(loginOptions({ userName: 'alice' })); connectWithOptions(loginOptions({ userName: 'alice' }));
openMockWebSocket(); openMockWebSocket();
deliverMessage(serverIdentification()); deliverMessage(serverIdentification());
expect(store.getState().server.status.state).toBe(App.StatusEnum.LOGGING_IN); expect(store.getState().server.status.state).toBe(StatusEnum.LOGGING_IN);
expect(store.getState().server.info.name).toBe('TestServer'); expect(store.getState().server.info.name).toBe('TestServer');
expect(store.getState().server.info.version).toBe('2.8.0'); expect(store.getState().server.info.version).toBe('2.8.0');
const { value, cmdId } = findLastSessionCommand(Data.Command_Login_ext); const { value, cmdId } = findLastSessionCommand(Data.Command_Login_ext);
expect(value.userName).toBe('alice'); expect(value.userName).toBe('alice');
expect(cmdId).toBeGreaterThan(0); expect(cmdId).toBeGreaterThan(0);
expect(getWebClient().options).toBeNull();
}); });
it('disconnects on protocol version mismatch without sending a login command', () => { it('disconnects on protocol version mismatch without sending a login command', () => {
getWebClient().connect(loginOptions()); connectWithOptions(loginOptions());
openMockWebSocket(); openMockWebSocket();
deliverMessage(serverIdentification(PROTOCOL_VERSION + 1)); deliverMessage(serverIdentification(PROTOCOL_VERSION + 1));
const mock = getMockWebSocket(); const mock = getMockWebSocket();
expect(mock.close).toHaveBeenCalled(); expect(mock.close).toHaveBeenCalled();
expect(store.getState().server.status.state).toBe(App.StatusEnum.DISCONNECTED); expect(store.getState().server.status.state).toBe(StatusEnum.DISCONNECTED);
expect(() => findLastSessionCommand(Data.Command_Login_ext)).toThrow(); expect(() => findLastSessionCommand(Data.Command_Login_ext)).toThrow();
}); });
it('times out when onopen never fires within the keepalive window', () => { it('times out when onopen never fires within the keepalive window', () => {
getWebClient().connect(loginOptions()); connectWithOptions(loginOptions());
const mock = getMockWebSocket(); const mock = getMockWebSocket();
expect(mock.close).not.toHaveBeenCalled(); expect(mock.close).not.toHaveBeenCalled();
@ -91,11 +103,11 @@ describe('connection lifecycle', () => {
vi.advanceTimersByTime(5000); vi.advanceTimersByTime(5000);
expect(mock.close).toHaveBeenCalled(); expect(mock.close).toHaveBeenCalled();
expect(store.getState().server.status.state).toBe(App.StatusEnum.DISCONNECTED); expect(store.getState().server.status.state).toBe(StatusEnum.DISCONNECTED);
}); });
it('releases keep-alive ping loop on explicit disconnect', () => { it('releases keep-alive ping loop on explicit disconnect', () => {
getWebClient().connect(loginOptions()); connectWithOptions(loginOptions());
openMockWebSocket(); openMockWebSocket();
deliverMessage(serverIdentification()); deliverMessage(serverIdentification());
@ -103,6 +115,20 @@ describe('connection lifecycle', () => {
getWebClient().disconnect(); getWebClient().disconnect();
expect(mock.close).toHaveBeenCalled(); expect(mock.close).toHaveBeenCalled();
expect(store.getState().server.status.state).toBe(App.StatusEnum.DISCONNECTED); expect(store.getState().server.status.state).toBe(StatusEnum.DISCONNECTED);
}); });
});
it('drops pending commands and clears state on unexpected socket close', () => {
connectAndHandshake();
// A login command is now pending (sent during handshake)
expect(() => findLastSessionCommand(Data.Command_Login_ext)).not.toThrow();
// Simulate unexpected socket close
const mock = getMockWebSocket();
mock.readyState = 3;
mock.onclose?.({ code: 1006, reason: '', wasClean: false } as CloseEvent);
expect(store.getState().server.status.state).toBe(StatusEnum.DISCONNECTED);
});
});

View file

@ -0,0 +1,119 @@
// Deck and replay command round-trips — validates the session command pipeline
// for deck CRUD and replay operations end-to-end.
import { create } from '@bufbuild/protobuf';
import { describe, expect, it } from 'vitest';
import { Data } from '@app/types';
import { store } from '@app/store';
import { SessionCommands } from '@app/websocket';
import { connectAndLogin } from './helpers/setup';
import {
buildResponse,
buildResponseMessage,
deliverMessage,
} from './helpers/protobuf-builders';
import { findLastSessionCommand } from './helpers/command-capture';
describe('deck operations', () => {
it('populates backendDecks from deckList response', () => {
connectAndLogin();
SessionCommands.deckList();
const { cmdId } = findLastSessionCommand(Data.Command_DeckList_ext);
const deckFile = create(Data.ServerInfo_DeckStorage_TreeItemSchema, {
id: 1,
name: 'MyDeck.cod',
file: create(Data.ServerInfo_DeckStorage_FileSchema, { creationTime: 1000 }),
});
const root = create(Data.ServerInfo_DeckStorage_FolderSchema, {
items: [deckFile],
});
deliverMessage(buildResponseMessage(buildResponse({
cmdId,
responseCode: Data.Response_ResponseCode.RespOk,
ext: Data.Response_DeckList_ext,
value: create(Data.Response_DeckListSchema, { root }),
})));
const backendDecks = store.getState().server.backendDecks;
expect(backendDecks).not.toBeNull();
expect(backendDecks?.root?.items).toHaveLength(1);
expect(backendDecks?.root?.items[0]?.name).toBe('MyDeck.cod');
});
it('populates downloadedDeck from deckDownload response', () => {
connectAndLogin();
SessionCommands.deckDownload(42);
const { cmdId } = findLastSessionCommand(Data.Command_DeckDownload_ext);
deliverMessage(buildResponseMessage(buildResponse({
cmdId,
responseCode: Data.Response_ResponseCode.RespOk,
ext: Data.Response_DeckDownload_ext,
value: create(Data.Response_DeckDownloadSchema, { deck: '4 Lightning Bolt\n20 Mountain' }),
})));
const downloaded = store.getState().server.downloadedDeck;
expect(downloaded).not.toBeNull();
expect(downloaded?.deckId).toBe(42);
expect(downloaded?.deck).toContain('Lightning Bolt');
});
});
describe('replay operations', () => {
it('populates replays from replayList response', () => {
connectAndLogin();
SessionCommands.replayList();
const { cmdId } = findLastSessionCommand(Data.Command_ReplayList_ext);
const match = create(Data.ServerInfo_ReplayMatchSchema, {
gameId: 99,
gameName: 'Casual Game',
});
deliverMessage(buildResponseMessage(buildResponse({
cmdId,
responseCode: Data.Response_ResponseCode.RespOk,
ext: Data.Response_ReplayList_ext,
value: create(Data.Response_ReplayListSchema, { matchList: [match] }),
})));
const replays = store.getState().server.replays;
expect(replays[99]).toBeDefined();
expect(replays[99].gameName).toBe('Casual Game');
});
it('removes replay from state on replayDeleteMatch round-trip', () => {
connectAndLogin();
// First populate a replay
SessionCommands.replayList();
const list = findLastSessionCommand(Data.Command_ReplayList_ext);
deliverMessage(buildResponseMessage(buildResponse({
cmdId: list.cmdId,
responseCode: Data.Response_ResponseCode.RespOk,
ext: Data.Response_ReplayList_ext,
value: create(Data.Response_ReplayListSchema, {
matchList: [create(Data.ServerInfo_ReplayMatchSchema, { gameId: 99, gameName: 'Old Game' })],
}),
})));
expect(store.getState().server.replays[99]).toBeDefined();
// Now delete it
SessionCommands.replayDeleteMatch(99);
const del = findLastSessionCommand(Data.Command_ReplayDeleteMatch_ext);
deliverMessage(buildResponseMessage(buildResponse({
cmdId: del.cmdId,
responseCode: Data.Response_ResponseCode.RespOk,
})));
expect(store.getState().server.replays[99]).toBeUndefined();
});
});

View file

@ -0,0 +1,416 @@
// Game scenarios — game join, state initialization, card operations,
// player counters, game chat, game close, and outbound game commands.
import { create } from '@bufbuild/protobuf';
import { describe, expect, it } from 'vitest';
import { Data } from '@app/types';
import { store } from '@app/store';
import { GameCommands, RoomCommands } from '@app/websocket';
import { connectAndHandshake, connectAndLogin } from './helpers/setup';
import {
buildResponse,
buildResponseMessage,
buildSessionEventMessage,
buildRoomEventMessage,
buildGameEventMessage,
deliverMessage,
} from './helpers/protobuf-builders';
import { findLastGameCommand, findLastRoomCommand, findLastSessionCommand } from './helpers/command-capture';
function joinGame(gameId: number): void {
deliverMessage(buildSessionEventMessage(
Data.Event_GameJoined_ext,
create(Data.Event_GameJoinedSchema, {
gameInfo: create(Data.ServerInfo_GameSchema, {
gameId,
description: 'Test Game',
maxPlayers: 2,
playerCount: 1,
}),
playerId: 1,
hostId: 1,
spectator: false,
judge: false,
resuming: false,
})
));
}
function setupGameState(gameId: number): void {
const deckCard = create(Data.ServerInfo_CardSchema, { id: 100, name: 'Forest' });
const handCard = create(Data.ServerInfo_CardSchema, { id: 101, name: 'Lightning Bolt' });
const deckZone = create(Data.ServerInfo_ZoneSchema, {
name: 'deck',
type: Data.ServerInfo_Zone_ZoneType.HiddenZone,
cardList: [deckCard],
});
const handZone = create(Data.ServerInfo_ZoneSchema, {
name: 'hand',
type: Data.ServerInfo_Zone_ZoneType.HiddenZone,
cardList: [handCard],
});
const tableZone = create(Data.ServerInfo_ZoneSchema, {
name: 'table',
type: Data.ServerInfo_Zone_ZoneType.PublicZone,
withCoords: true,
cardList: [],
});
const player = create(Data.ServerInfo_PlayerSchema, {
properties: create(Data.ServerInfo_PlayerPropertiesSchema, {
playerId: 1,
userInfo: create(Data.ServerInfo_UserSchema, { name: 'alice' }),
}),
zoneList: [deckZone, handZone, tableZone],
counterList: [],
arrowList: [],
});
deliverMessage(buildGameEventMessage({
gameId,
playerId: -1,
ext: Data.Event_GameStateChanged_ext,
value: create(Data.Event_GameStateChangedSchema, {
playerList: [player],
gameStarted: true,
activePlayerId: 1,
activePhase: 0,
}),
}));
}
describe('game', () => {
it('initializes game state from Event_GameJoined + Event_GameStateChanged', () => {
connectAndLogin();
joinGame(42);
const game = store.getState().games.games[42];
expect(game).toBeDefined();
expect(game.info.description).toBe('Test Game');
expect(game.localPlayerId).toBe(1);
setupGameState(42);
const updated = store.getState().games.games[42];
expect(updated.started).toBe(true);
expect(updated.activePlayerId).toBe(1);
expect(updated.players[1]).toBeDefined();
expect(updated.players[1].zones.hand).toBeDefined();
expect(updated.players[1].zones.deck).toBeDefined();
expect(updated.players[1].zones.hand.order).toContain(101);
expect(updated.players[1].zones.deck.order).toContain(100);
});
it('draws cards from deck to hand on Event_DrawCards', () => {
connectAndLogin();
joinGame(42);
setupGameState(42);
const drawnCard = create(Data.ServerInfo_CardSchema, { id: 200, name: 'Mountain' });
deliverMessage(buildGameEventMessage({
gameId: 42,
playerId: 1,
ext: Data.Event_DrawCards_ext,
value: create(Data.Event_DrawCardsSchema, {
number: 1,
cards: [drawnCard],
}),
}));
const player = store.getState().games.games[42].players[1];
expect(player.zones.hand.order).toContain(200);
expect(player.zones.hand.byId[200]?.name).toBe('Mountain');
});
it('appends chat messages on Event_GameSay', () => {
connectAndLogin();
joinGame(42);
deliverMessage(buildGameEventMessage({
gameId: 42,
playerId: 1,
ext: Data.Event_GameSay_ext,
value: create(Data.Event_GameSaySchema, { message: 'good game' }),
}));
const messages = store.getState().games.games[42].messages;
expect(messages).toHaveLength(1);
expect(messages[0].message).toBe('good game');
expect(messages[0].playerId).toBe(1);
});
it('removes game from store on Event_GameClosed', () => {
connectAndLogin();
joinGame(42);
expect(store.getState().games.games[42]).toBeDefined();
deliverMessage(buildGameEventMessage({
gameId: 42,
playerId: -1,
ext: Data.Event_GameClosed_ext,
value: create(Data.Event_GameClosedSchema),
}));
expect(store.getState().games.games[42]).toBeUndefined();
});
it('sends outbound Command_GameSay with correct gameId and message', () => {
connectAndLogin();
joinGame(42);
GameCommands.gameSay(42, { message: 'hello opponent' });
const { value, cmdId } = findLastGameCommand(Data.Command_GameSay_ext);
expect(value.message).toBe('hello opponent');
expect(cmdId).toBeGreaterThan(0);
});
it('moves a card from hand to table on Event_MoveCard', () => {
connectAndLogin();
joinGame(42);
setupGameState(42);
deliverMessage(buildGameEventMessage({
gameId: 42,
playerId: 1,
ext: Data.Event_MoveCard_ext,
value: create(Data.Event_MoveCardSchema, {
cardId: 101,
cardName: 'Lightning Bolt',
startPlayerId: 1,
startZone: 'hand',
targetPlayerId: 1,
targetZone: 'table',
x: 100,
y: 200,
faceDown: false,
newCardId: 101,
}),
}));
const player = store.getState().games.games[42].players[1];
expect(player.zones.hand.order).not.toContain(101);
expect(player.zones.table.order).toContain(101);
expect(player.zones.table.byId[101]?.name).toBe('Lightning Bolt');
expect(player.zones.table.byId[101]?.x).toBe(100);
});
it('creates and updates player counters', () => {
connectAndLogin();
joinGame(42);
setupGameState(42);
const counterInfo = create(Data.ServerInfo_CounterSchema, {
id: 1,
name: 'Life',
count: 20,
radius: 1,
});
deliverMessage(buildGameEventMessage({
gameId: 42,
playerId: 1,
ext: Data.Event_CreateCounter_ext,
value: create(Data.Event_CreateCounterSchema, { counterInfo }),
}));
const player = store.getState().games.games[42].players[1];
expect(player.counters[1]).toBeDefined();
expect(player.counters[1].name).toBe('Life');
expect(player.counters[1].count).toBe(20);
deliverMessage(buildGameEventMessage({
gameId: 42,
playerId: 1,
ext: Data.Event_SetCounter_ext,
value: create(Data.Event_SetCounterSchema, { counterId: 1, value: 17 }),
}));
expect(store.getState().games.games[42].players[1].counters[1].count).toBe(17);
});
it('full lifecycle: create → join → deck select → draw → chat → discard → concede → leave', () => {
connectAndHandshake();
// ── Setup: join a room so we can create a game in it ──────────────────
deliverMessage(buildSessionEventMessage(
Data.Event_ListRooms_ext,
create(Data.Event_ListRoomsSchema, {
roomList: [create(Data.ServerInfo_RoomSchema, { roomId: 1, autoJoin: true, gameList: [], userList: [], gametypeList: [] })],
})
));
const roomJoin = findLastSessionCommand(Data.Command_JoinRoom_ext);
deliverMessage(buildResponseMessage(buildResponse({
cmdId: roomJoin.cmdId,
responseCode: Data.Response_ResponseCode.RespOk,
ext: Data.Response_JoinRoom_ext,
value: create(Data.Response_JoinRoomSchema, {
roomInfo: create(Data.ServerInfo_RoomSchema, { roomId: 1, gameList: [], userList: [], gametypeList: [] }),
}),
})));
// ── 1. Create game ───────────────────────────────────────────────────
RoomCommands.createGame(1, { description: 'Ranked Match', maxPlayers: 2 });
const createCmd = findLastRoomCommand(Data.Command_CreateGame_ext);
deliverMessage(buildResponseMessage(buildResponse({
cmdId: createCmd.cmdId,
responseCode: Data.Response_ResponseCode.RespOk,
})));
// ── 2. Join game ─────────────────────────────────────────────────────
RoomCommands.joinGame(1, { gameId: 99 });
const joinCmd = findLastRoomCommand(Data.Command_JoinGame_ext);
deliverMessage(buildResponseMessage(buildResponse({
cmdId: joinCmd.cmdId,
responseCode: Data.Response_ResponseCode.RespOk,
})));
expect(store.getState().rooms.joinedGameIds[1]?.[99]).toBe(true);
// Server sends Event_GameJoined (session event)
deliverMessage(buildSessionEventMessage(
Data.Event_GameJoined_ext,
create(Data.Event_GameJoinedSchema, {
gameInfo: create(Data.ServerInfo_GameSchema, { gameId: 99, description: 'Ranked Match', maxPlayers: 2 }),
playerId: 1,
hostId: 1,
spectator: false,
judge: false,
resuming: false,
})
));
expect(store.getState().games.games[99]).toBeDefined();
// ── 3. Select deck ───────────────────────────────────────────────────
GameCommands.deckSelect(99, { deck: '4 Lightning Bolt\n20 Mountain\n4 Goblin Guide' });
const deckCmd = findLastGameCommand(Data.Command_DeckSelect_ext);
expect(deckCmd.value.deck).toContain('Lightning Bolt');
// Server responds with full game state (deck in zones)
const deckCards = [
create(Data.ServerInfo_CardSchema, { id: 1, name: 'Lightning Bolt' }),
create(Data.ServerInfo_CardSchema, { id: 2, name: 'Mountain' }),
create(Data.ServerInfo_CardSchema, { id: 3, name: 'Goblin Guide' }),
];
deliverMessage(buildGameEventMessage({
gameId: 99,
playerId: -1,
ext: Data.Event_GameStateChanged_ext,
value: create(Data.Event_GameStateChangedSchema, {
playerList: [create(Data.ServerInfo_PlayerSchema, {
properties: create(Data.ServerInfo_PlayerPropertiesSchema, {
playerId: 1,
userInfo: create(Data.ServerInfo_UserSchema, { name: 'alice' }),
}),
zoneList: [
create(Data.ServerInfo_ZoneSchema, { name: 'deck', type: Data.ServerInfo_Zone_ZoneType.HiddenZone, cardList: deckCards, cardCount: 3 }),
create(Data.ServerInfo_ZoneSchema, { name: 'hand', type: Data.ServerInfo_Zone_ZoneType.HiddenZone, cardList: [], cardCount: 0 }),
create(Data.ServerInfo_ZoneSchema, { name: 'table', type: Data.ServerInfo_Zone_ZoneType.PublicZone, withCoords: true, cardList: [], cardCount: 0 }),
create(Data.ServerInfo_ZoneSchema, { name: 'grave', type: Data.ServerInfo_Zone_ZoneType.PublicZone, cardList: [], cardCount: 0 }),
],
counterList: [],
arrowList: [],
})],
gameStarted: true,
activePlayerId: 1,
activePhase: 0,
}),
}));
const gameAfterDeck = store.getState().games.games[99];
expect(gameAfterDeck.players[1].zones.deck.order).toHaveLength(3);
expect(gameAfterDeck.players[1].zones.hand.order).toHaveLength(0);
// ── 4. Draw cards ────────────────────────────────────────────────────
deliverMessage(buildGameEventMessage({
gameId: 99,
playerId: 1,
ext: Data.Event_DrawCards_ext,
value: create(Data.Event_DrawCardsSchema, {
number: 2,
cards: [
create(Data.ServerInfo_CardSchema, { id: 1, name: 'Lightning Bolt' }),
create(Data.ServerInfo_CardSchema, { id: 2, name: 'Mountain' }),
],
}),
}));
const afterDraw = store.getState().games.games[99].players[1];
expect(afterDraw.zones.hand.order).toHaveLength(2);
expect(afterDraw.zones.hand.order).toContain(1);
expect(afterDraw.zones.hand.order).toContain(2);
expect(afterDraw.zones.deck.cardCount).toBe(1);
// ── 5. Send game message ─────────────────────────────────────────────
GameCommands.gameSay(99, { message: 'good luck!' });
const sayCmd = findLastGameCommand(Data.Command_GameSay_ext);
expect(sayCmd.value.message).toBe('good luck!');
// Server echoes the message back
deliverMessage(buildGameEventMessage({
gameId: 99,
playerId: 1,
ext: Data.Event_GameSay_ext,
value: create(Data.Event_GameSaySchema, { message: 'good luck!' }),
}));
expect(store.getState().games.games[99].messages).toHaveLength(1);
// ── 6. Discard (move card from hand to graveyard) ────────────────────
deliverMessage(buildGameEventMessage({
gameId: 99,
playerId: 1,
ext: Data.Event_MoveCard_ext,
value: create(Data.Event_MoveCardSchema, {
cardId: 1,
cardName: 'Lightning Bolt',
startPlayerId: 1,
startZone: 'hand',
targetPlayerId: 1,
targetZone: 'grave',
faceDown: false,
newCardId: 1,
}),
}));
const afterDiscard = store.getState().games.games[99].players[1];
expect(afterDiscard.zones.hand.order).not.toContain(1);
expect(afterDiscard.zones.grave.order).toContain(1);
expect(afterDiscard.zones.grave.byId[1]?.name).toBe('Lightning Bolt');
// ── 7. Concede ───────────────────────────────────────────────────────
GameCommands.concede(99);
expect(() => findLastGameCommand(Data.Command_Concede_ext)).not.toThrow();
// Server confirms concession
deliverMessage(buildGameEventMessage({
gameId: 99,
playerId: 1,
ext: Data.Event_PlayerPropertiesChanged_ext,
value: create(Data.Event_PlayerPropertiesChangedSchema, {
playerProperties: create(Data.ServerInfo_PlayerPropertiesSchema, {
playerId: 1,
conceded: true,
userInfo: create(Data.ServerInfo_UserSchema, { name: 'alice' }),
}),
}),
}));
expect(store.getState().games.games[99].players[1].properties.conceded).toBe(true);
// ── 8. Leave game ────────────────────────────────────────────────────
GameCommands.leaveGame(99);
expect(() => findLastGameCommand(Data.Command_LeaveGame_ext)).not.toThrow();
// Server confirms player left
deliverMessage(buildGameEventMessage({
gameId: 99,
playerId: 1,
ext: Data.Event_Leave_ext,
value: create(Data.Event_LeaveSchema, { reason: Data.Event_Leave_LeaveReason.USER_LEFT }),
}));
expect(store.getState().games.games[99].players[1]).toBeUndefined();
});
});

View file

@ -11,10 +11,12 @@ import { Data } from '@app/types';
import { getMockWebSocket } from './setup'; import { getMockWebSocket } from './setup';
/** The three command scopes a CommandContainer can carry in practice. */ /** The command scopes a CommandContainer can carry in practice. */
type SessionCmd = Data.SessionCommand; type SessionCmd = Data.SessionCommand;
type RoomCmd = Data.RoomCommand; type RoomCmd = Data.RoomCommand;
type GameCmd = Data.GameCommand; type GameCmd = Data.GameCommand;
type AdminCmd = Data.AdminCommand;
type ModeratorCmd = Data.ModeratorCommand;
/** Decode every CommandContainer sent through the mock socket so far. */ /** Decode every CommandContainer sent through the mock socket so far. */
export function captureAllOutbound(): Data.CommandContainer[] { export function captureAllOutbound(): Data.CommandContainer[] {
@ -110,3 +112,47 @@ export function findLastGameCommand<V>(
`No outbound game command with extension ${ext.typeName} has been sent.` `No outbound game command with extension ${ext.typeName} has been sent.`
); );
} }
/** Admin-scoped equivalent of {@link findLastSessionCommand}. */
export function findLastAdminCommand<V>(
ext: GenExtension<AdminCmd, V>
): { container: Data.CommandContainer; value: V; cmdId: number } {
const containers = captureAllOutbound();
for (let i = containers.length - 1; i >= 0; i--) {
const container = containers[i];
for (const adminCmd of container.adminCommand ?? []) {
if (hasExtension(adminCmd, ext)) {
return {
container,
value: getExtension(adminCmd, ext),
cmdId: Number(container.cmdId),
};
}
}
}
throw new Error(
`No outbound admin command with extension ${ext.typeName} has been sent.`
);
}
/** Moderator-scoped equivalent of {@link findLastSessionCommand}. */
export function findLastModeratorCommand<V>(
ext: GenExtension<ModeratorCmd, V>
): { container: Data.CommandContainer; value: V; cmdId: number } {
const containers = captureAllOutbound();
for (let i = containers.length - 1; i >= 0; i--) {
const container = containers[i];
for (const modCmd of container.moderatorCommand ?? []) {
if (hasExtension(modCmd, ext)) {
return {
container,
value: getExtension(modCmd, ext),
cmdId: Number(container.cmdId),
};
}
}
}
throw new Error(
`No outbound moderator command with extension ${ext.typeName} has been sent.`
);
}

View file

@ -14,10 +14,16 @@ import { create } from '@bufbuild/protobuf';
import { afterEach, beforeEach, vi } from 'vitest'; import { afterEach, beforeEach, vi } from 'vitest';
import { ServerDispatch, RoomsDispatch, GameDispatch } from '@app/store'; import { ServerDispatch, RoomsDispatch, GameDispatch } from '@app/store';
import { App, Data, Enriched } from '@app/types'; import { Data } from '@app/types';
import { WebClient } from '@app/websocket'; import {
WebClient,
StatusEnum,
WebSocketConnectReason,
setPendingOptions,
} from '@app/websocket';
import type { WebSocketConnectOptions } from '@app/websocket';
import { PROTOCOL_VERSION } from '../../../src/websocket/config'; import { PROTOCOL_VERSION } from '../../../src/websocket/config';
import { createWebClientResponse, createWebClientRequest } from '@app/api'; import { initWebClient } from '@app/api';
import { import {
buildResponse, buildResponse,
@ -27,6 +33,8 @@ import {
} from './protobuf-builders'; } from './protobuf-builders';
import { findLastSessionCommand } from './command-capture'; import { findLastSessionCommand } from './command-capture';
export { setPendingOptions };
export interface MockWebSocketInstance { export interface MockWebSocketInstance {
send: ReturnType<typeof vi.fn>; send: ReturnType<typeof vi.fn>;
close: ReturnType<typeof vi.fn>; close: ReturnType<typeof vi.fn>;
@ -97,8 +105,7 @@ function resetAll(): void {
} }
client.protobuf.resetCommands(); client.protobuf.resetCommands();
client.options = null; client.status = StatusEnum.DISCONNECTED;
client.status = App.StatusEnum.DISCONNECTED;
ServerDispatch.clearStore(); ServerDispatch.clearStore();
RoomsDispatch.clearStore(); RoomsDispatch.clearStore();
@ -117,8 +124,8 @@ function resetAll(): void {
// ── Shared connect helpers ────────────────────────────────────────────────── // ── Shared connect helpers ──────────────────────────────────────────────────
const DEFAULT_LOGIN_OPTIONS: Enriched.LoginConnectOptions = { const DEFAULT_LOGIN_OPTIONS: WebSocketConnectOptions = {
reason: App.WebSocketConnectReason.LOGIN, reason: WebSocketConnectReason.LOGIN,
host: 'localhost', host: 'localhost',
port: '4748', port: '4748',
userName: 'alice', userName: 'alice',
@ -126,14 +133,16 @@ const DEFAULT_LOGIN_OPTIONS: Enriched.LoginConnectOptions = {
}; };
export function connectRaw( export function connectRaw(
overrides: Partial<Enriched.LoginConnectOptions> = {} overrides: Partial<WebSocketConnectOptions> = {}
): void { ): void {
getWebClient().connect({ ...DEFAULT_LOGIN_OPTIONS, ...overrides }); const opts = { ...DEFAULT_LOGIN_OPTIONS, ...overrides };
setPendingOptions(opts as WebSocketConnectOptions);
getWebClient().connect({ host: opts.host, port: opts.port });
openMockWebSocket(); openMockWebSocket();
} }
export function connectAndHandshake( export function connectAndHandshake(
overrides: Partial<Enriched.LoginConnectOptions> = {} overrides: Partial<WebSocketConnectOptions> = {}
): void { ): void {
connectRaw(overrides); connectRaw(overrides);
deliverMessage(buildSessionEventMessage( deliverMessage(buildSessionEventMessage(
@ -146,6 +155,21 @@ export function connectAndHandshake(
)); ));
} }
export function connectAndHandshakeWithSalt(
overrides: Partial<WebSocketConnectOptions> = {}
): void {
connectRaw(overrides);
deliverMessage(buildSessionEventMessage(
Data.Event_ServerIdentification_ext,
create(Data.Event_ServerIdentificationSchema, {
serverName: 'TestServer',
serverVersion: '2.8.0',
protocolVersion: PROTOCOL_VERSION,
serverOptions: Data.Event_ServerIdentification_ServerOptions.SupportsPasswordHash,
})
));
}
export function connectAndLogin(userName: string = 'alice'): void { export function connectAndLogin(userName: string = 'alice'): void {
connectAndHandshake({ userName }); connectAndHandshake({ userName });
@ -172,7 +196,7 @@ installMockWebSocket();
beforeEach(() => { beforeEach(() => {
vi.useFakeTimers(); vi.useFakeTimers();
new WebClient(createWebClientResponse(), createWebClientRequest()); initWebClient();
}); });
afterEach(() => { afterEach(() => {

View file

@ -2,8 +2,9 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { App, Data } from '@app/types'; import { Data } from '@app/types';
import { store } from '@app/store'; import { store } from '@app/store';
import { StatusEnum } from '@app/websocket';
import { connectRaw, getMockWebSocket } from './helpers/setup'; import { connectRaw, getMockWebSocket } from './helpers/setup';
import { import {
@ -31,7 +32,7 @@ describe('keep-alive', () => {
vi.advanceTimersByTime(5000); vi.advanceTimersByTime(5000);
const second = findLastSessionCommand(Data.Command_Ping_ext); const second = findLastSessionCommand(Data.Command_Ping_ext);
expect(second.cmdId).toBeGreaterThan(first.cmdId); expect(second.cmdId).toBeGreaterThan(first.cmdId);
expect(store.getState().server.status.state).toBe(App.StatusEnum.CONNECTED); expect(store.getState().server.status.state).toBe(StatusEnum.CONNECTED);
}); });
it('stays CONNECTED while pongs arrive before the next tick', () => { it('stays CONNECTED while pongs arrive before the next tick', () => {
@ -46,7 +47,7 @@ describe('keep-alive', () => {
}))); })));
} }
expect(store.getState().server.status.state).toBe(App.StatusEnum.CONNECTED); expect(store.getState().server.status.state).toBe(StatusEnum.CONNECTED);
expect(getMockWebSocket().close).not.toHaveBeenCalled(); expect(getMockWebSocket().close).not.toHaveBeenCalled();
}); });
@ -55,11 +56,11 @@ describe('keep-alive', () => {
vi.advanceTimersByTime(5000); vi.advanceTimersByTime(5000);
expect(() => findLastSessionCommand(Data.Command_Ping_ext)).not.toThrow(); expect(() => findLastSessionCommand(Data.Command_Ping_ext)).not.toThrow();
expect(store.getState().server.status.state).toBe(App.StatusEnum.CONNECTED); expect(store.getState().server.status.state).toBe(StatusEnum.CONNECTED);
vi.advanceTimersByTime(5000); vi.advanceTimersByTime(5000);
expect(getMockWebSocket().close).toHaveBeenCalled(); expect(getMockWebSocket().close).toHaveBeenCalled();
expect(store.getState().server.status.state).toBe(App.StatusEnum.DISCONNECTED); expect(store.getState().server.status.state).toBe(StatusEnum.DISCONNECTED);
}); });
}); });

View file

@ -0,0 +1,104 @@
// Moderator command pipeline smoke tests — validates that sendModeratorCommand
// encodes, correlates, and persists correctly end-to-end. One test per
// distinct response pattern (simple vs. extension-payload).
import { create } from '@bufbuild/protobuf';
import { describe, expect, it } from 'vitest';
import { Data } from '@app/types';
import { store } from '@app/store';
import { ModeratorCommands } from '@app/websocket';
import { connectAndLogin } from './helpers/setup';
import {
buildResponse,
buildResponseMessage,
deliverMessage,
} from './helpers/protobuf-builders';
import { findLastModeratorCommand } from './helpers/command-capture';
describe('moderator commands', () => {
it('getBanHistory populates server.banHistory on success', () => {
connectAndLogin();
ModeratorCommands.getBanHistory('baduser');
const { cmdId, value } = findLastModeratorCommand(Data.Command_GetBanHistory_ext);
expect(value.userName).toBe('baduser');
const banEntry = create(Data.ServerInfo_BanSchema, {
adminId: 'admin1',
adminName: 'Admin',
banTime: '2026-01-01',
banLength: '60',
visibleReason: 'spamming',
});
deliverMessage(buildResponseMessage(buildResponse({
cmdId,
responseCode: Data.Response_ResponseCode.RespOk,
ext: Data.Response_BanHistory_ext,
value: create(Data.Response_BanHistorySchema, { banList: [banEntry] }),
})));
const history = store.getState().server.banHistory.baduser;
expect(history).toHaveLength(1);
expect(history[0].visibleReason).toBe('spamming');
});
it('viewLogHistory populates server.logs on success', () => {
connectAndLogin();
ModeratorCommands.viewLogHistory({ dateRange: 30 });
const { cmdId } = findLastModeratorCommand(Data.Command_ViewLogHistory_ext);
const logMsg = create(Data.ServerInfo_ChatMessageSchema, {
senderName: 'alice',
message: 'test message',
});
deliverMessage(buildResponseMessage(buildResponse({
cmdId,
responseCode: Data.Response_ResponseCode.RespOk,
ext: Data.Response_ViewLogHistory_ext,
value: create(Data.Response_ViewLogHistorySchema, { logMessage: [logMsg] }),
})));
const logs = store.getState().server.logs;
expect(Object.keys(logs).length).toBeGreaterThan(0);
});
it('warnUser sends command and updates state on success', () => {
connectAndLogin();
ModeratorCommands.warnUser('troublemaker', 'spamming chat');
const { cmdId, value } = findLastModeratorCommand(Data.Command_WarnUser_ext);
expect(value.userName).toBe('troublemaker');
expect(value.reason).toBe('spamming chat');
deliverMessage(buildResponseMessage(buildResponse({
cmdId,
responseCode: Data.Response_ResponseCode.RespOk,
})));
expect(store.getState().server.warnUser).toBe('troublemaker');
});
it('banFromServer sends command and updates state on success', () => {
connectAndLogin();
ModeratorCommands.banFromServer(60, 'baduser', undefined, 'repeated offenses', 'rule violation');
const { cmdId, value } = findLastModeratorCommand(Data.Command_BanFromServer_ext);
expect(value.userName).toBe('baduser');
expect(value.minutes).toBe(60);
expect(value.visibleReason).toBe('rule violation');
deliverMessage(buildResponseMessage(buildResponse({
cmdId,
responseCode: Data.Response_ResponseCode.RespOk,
})));
expect(store.getState().server.banUser).toBe('baduser');
});
});

View file

@ -0,0 +1,86 @@
// Password-reset scenarios — the 3-step forgot-password flow. Each step
// is a separate connect → handshake → command → disconnect cycle.
import { create } from '@bufbuild/protobuf';
import { describe, expect, it } from 'vitest';
import { Data } from '@app/types';
import { store } from '@app/store';
import { StatusEnum, WebSocketConnectReason } from '@app/websocket';
import { connectAndHandshake } from './helpers/setup';
import {
buildResponse,
buildResponseMessage,
deliverMessage,
} from './helpers/protobuf-builders';
import { findLastSessionCommand } from './helpers/command-capture';
describe('password reset', () => {
it('forgotPasswordRequest sends command and disconnects on success', () => {
connectAndHandshake({
reason: WebSocketConnectReason.PASSWORD_RESET_REQUEST as const,
host: 'localhost',
port: '4748',
userName: 'alice',
});
const req = findLastSessionCommand(Data.Command_ForgotPasswordRequest_ext);
expect(req.value.userName).toBe('alice');
deliverMessage(buildResponseMessage(buildResponse({
cmdId: req.cmdId,
responseCode: Data.Response_ResponseCode.RespOk,
ext: Data.Response_ForgotPasswordRequest_ext,
value: create(Data.Response_ForgotPasswordRequestSchema, {
challengeEmail: 'a@example.com',
}),
})));
expect(store.getState().server.status.state).toBe(StatusEnum.DISCONNECTED);
});
it('forgotPasswordChallenge sends command with userName and email', () => {
connectAndHandshake({
reason: WebSocketConnectReason.PASSWORD_RESET_CHALLENGE as const,
host: 'localhost',
port: '4748',
userName: 'alice',
email: 'alice@example.com',
});
const challenge = findLastSessionCommand(Data.Command_ForgotPasswordChallenge_ext);
expect(challenge.value.userName).toBe('alice');
expect(challenge.value.email).toBe('alice@example.com');
deliverMessage(buildResponseMessage(buildResponse({
cmdId: challenge.cmdId,
responseCode: Data.Response_ResponseCode.RespOk,
})));
expect(store.getState().server.status.state).toBe(StatusEnum.DISCONNECTED);
});
it('forgotPasswordReset sends command with userName, token, and newPassword', () => {
connectAndHandshake({
reason: WebSocketConnectReason.PASSWORD_RESET as const,
host: 'localhost',
port: '4748',
userName: 'alice',
token: 'reset-token-123',
newPassword: 'new-secret',
});
const reset = findLastSessionCommand(Data.Command_ForgotPasswordReset_ext);
expect(reset.value.userName).toBe('alice');
expect(reset.value.token).toBe('reset-token-123');
expect(reset.value.newPassword).toBe('new-secret');
deliverMessage(buildResponseMessage(buildResponse({
cmdId: reset.cmdId,
responseCode: Data.Response_ResponseCode.RespOk,
})));
expect(store.getState().server.status.state).toBe(StatusEnum.DISCONNECTED);
});
});

View file

@ -1,11 +1,12 @@
// Room scenarios — Event_ListRooms handling, auto-join, Response_JoinRoom, // Room scenarios — Event_ListRooms handling, auto-join, Response_JoinRoom,
// room chat, and in-room game list updates. // room chat (inbound + outbound), game list updates, and leaveRoom.
import { create } from '@bufbuild/protobuf'; import { create } from '@bufbuild/protobuf';
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { Data } from '@app/types'; import { Data } from '@app/types';
import { store } from '@app/store'; import { store } from '@app/store';
import { RoomCommands } from '@app/websocket';
import { connectAndHandshake } from './helpers/setup'; import { connectAndHandshake } from './helpers/setup';
import { import {
@ -15,7 +16,8 @@ import {
buildSessionEventMessage, buildSessionEventMessage,
deliverMessage, deliverMessage,
} from './helpers/protobuf-builders'; } from './helpers/protobuf-builders';
import { findLastSessionCommand } from './helpers/command-capture'; import { findLastSessionCommand, findLastRoomCommand, captureAllOutbound } from './helpers/command-capture';
import { fromBinary, hasExtension, getExtension } from '@bufbuild/protobuf';
function makeRoom(overrides: Partial<{ function makeRoom(overrides: Partial<{
roomId: number; roomId: number;
@ -35,6 +37,21 @@ function makeRoom(overrides: Partial<{
}); });
} }
/** Deliver Event_ListRooms then join a single auto-join room, returning the roomId. */
function setupJoinedRoom(roomId = 1): void {
deliverMessage(buildSessionEventMessage(
Data.Event_ListRooms_ext,
create(Data.Event_ListRoomsSchema, { roomList: [makeRoom({ roomId, autoJoin: true })] })
));
const join = findLastSessionCommand(Data.Command_JoinRoom_ext);
deliverMessage(buildResponseMessage(buildResponse({
cmdId: join.cmdId,
responseCode: Data.Response_ResponseCode.RespOk,
ext: Data.Response_JoinRoom_ext,
value: create(Data.Response_JoinRoomSchema, { roomInfo: makeRoom({ roomId }) }),
})));
}
describe('rooms', () => { describe('rooms', () => {
it('populates rooms state from Event_ListRooms', () => { it('populates rooms state from Event_ListRooms', () => {
connectAndHandshake(); connectAndHandshake();
@ -81,18 +98,7 @@ describe('rooms', () => {
it('appends a room chat message on Event_RoomSay', () => { it('appends a room chat message on Event_RoomSay', () => {
connectAndHandshake(); connectAndHandshake();
setupJoinedRoom(1);
deliverMessage(buildSessionEventMessage(
Data.Event_ListRooms_ext,
create(Data.Event_ListRoomsSchema, { roomList: [makeRoom({ roomId: 1, autoJoin: true })] })
));
const join = findLastSessionCommand(Data.Command_JoinRoom_ext);
deliverMessage(buildResponseMessage(buildResponse({
cmdId: join.cmdId,
responseCode: Data.Response_ResponseCode.RespOk,
ext: Data.Response_JoinRoom_ext,
value: create(Data.Response_JoinRoomSchema, { roomInfo: makeRoom({ roomId: 1 }) }),
})));
const say = create(Data.Event_RoomSaySchema, { const say = create(Data.Event_RoomSaySchema, {
name: 'bob', name: 'bob',
@ -109,18 +115,7 @@ describe('rooms', () => {
it('updates the game list on Event_ListGames', () => { it('updates the game list on Event_ListGames', () => {
connectAndHandshake(); connectAndHandshake();
setupJoinedRoom(1);
deliverMessage(buildSessionEventMessage(
Data.Event_ListRooms_ext,
create(Data.Event_ListRoomsSchema, { roomList: [makeRoom({ roomId: 1, autoJoin: true })] })
));
const join = findLastSessionCommand(Data.Command_JoinRoom_ext);
deliverMessage(buildResponseMessage(buildResponse({
cmdId: join.cmdId,
responseCode: Data.Response_ResponseCode.RespOk,
ext: Data.Response_JoinRoom_ext,
value: create(Data.Response_JoinRoomSchema, { roomInfo: makeRoom({ roomId: 1 }) }),
})));
const game = create(Data.ServerInfo_GameSchema, { const game = create(Data.ServerInfo_GameSchema, {
gameId: 42, gameId: 42,
@ -137,4 +132,102 @@ describe('rooms', () => {
expect(roomGames?.[42]?.info?.description).toBe('Test Game'); expect(roomGames?.[42]?.info?.description).toBe('Test Game');
expect(roomGames?.[42]?.info?.gameId).toBe(42); expect(roomGames?.[42]?.info?.gameId).toBe(42);
}); });
});
it('auto-join filters correctly across multiple rooms', () => {
connectAndHandshake();
deliverMessage(buildSessionEventMessage(
Data.Event_ListRooms_ext,
create(Data.Event_ListRoomsSchema, {
roomList: [
makeRoom({ roomId: 1, name: 'Lobby', autoJoin: true }),
makeRoom({ roomId: 2, name: 'Legacy', autoJoin: false }),
makeRoom({ roomId: 3, name: 'Modern', autoJoin: true }),
],
})
));
// Count outbound JoinRoom commands
const containers = captureAllOutbound();
const joinCommands: number[] = [];
for (const container of containers) {
for (const cmd of container.sessionCommand ?? []) {
if (hasExtension(cmd, Data.Command_JoinRoom_ext)) {
joinCommands.push(getExtension(cmd, Data.Command_JoinRoom_ext).roomId);
}
}
}
expect(joinCommands).toHaveLength(2);
expect(joinCommands).toContain(1);
expect(joinCommands).toContain(3);
expect(joinCommands).not.toContain(2);
});
it('sends outbound Command_RoomSay with trimmed message', () => {
connectAndHandshake();
setupJoinedRoom(1);
RoomCommands.roomSay(1, ' hello ');
const { value } = findLastRoomCommand(Data.Command_RoomSay_ext);
expect(value.message).toBe('hello');
});
it('removes room from joinedRoomIds on leaveRoom round-trip', () => {
connectAndHandshake();
setupJoinedRoom(1);
expect(store.getState().rooms.joinedRoomIds[1]).toBe(true);
RoomCommands.leaveRoom(1);
const leave = findLastRoomCommand(Data.Command_LeaveRoom_ext);
deliverMessage(buildResponseMessage(buildResponse({
cmdId: leave.cmdId,
responseCode: Data.Response_ResponseCode.RespOk,
})));
expect(store.getState().rooms.joinedRoomIds[1]).toBeUndefined();
});
it('tracks user join and leave within a room', () => {
connectAndHandshake();
setupJoinedRoom(1);
deliverMessage(buildRoomEventMessage(1, Data.Event_JoinRoom_ext, create(Data.Event_JoinRoomSchema, {
userInfo: create(Data.ServerInfo_UserSchema, { name: 'bob' }),
})));
expect(store.getState().rooms.rooms[1]?.users?.bob).toBeDefined();
deliverMessage(buildRoomEventMessage(1, Data.Event_LeaveRoom_ext, create(Data.Event_LeaveRoomSchema, {
name: 'bob',
})));
expect(store.getState().rooms.rooms[1]?.users?.bob).toBeUndefined();
});
it('tracks game creation and join within a room', () => {
connectAndHandshake();
setupJoinedRoom(1);
RoomCommands.createGame(1, { description: 'Casual', maxPlayers: 2 });
const create_ = findLastRoomCommand(Data.Command_CreateGame_ext);
expect(create_.value.description).toBe('Casual');
deliverMessage(buildResponseMessage(buildResponse({
cmdId: create_.cmdId,
responseCode: Data.Response_ResponseCode.RespOk,
})));
RoomCommands.joinGame(1, { gameId: 99 });
const join = findLastRoomCommand(Data.Command_JoinGame_ext);
deliverMessage(buildResponseMessage(buildResponse({
cmdId: join.cmdId,
responseCode: Data.Response_ResponseCode.RespOk,
})));
expect(store.getState().rooms.joinedGameIds[1]?.[99]).toBe(true);
});
});

View file

@ -4,8 +4,9 @@
import { create } from '@bufbuild/protobuf'; import { create } from '@bufbuild/protobuf';
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { App, Data } from '@app/types'; import { Data } from '@app/types';
import { store } from '@app/store'; import { store } from '@app/store';
import { StatusEnum } from '@app/websocket';
import { connectAndHandshake } from './helpers/setup'; import { connectAndHandshake } from './helpers/setup';
import { import {
@ -72,7 +73,7 @@ describe('server events', () => {
)); ));
const status = store.getState().server.status; const status = store.getState().server.status;
expect(status.state).toBe(App.StatusEnum.DISCONNECTED); expect(status.state).toBe(StatusEnum.DISCONNECTED);
expect(status.description).toBe('kicked by admin'); expect(status.description).toBe('kicked by admin');
}); });

View file

@ -117,4 +117,4 @@ describe('users', () => {
expect(messages.bob).toHaveLength(1); expect(messages.bob).toHaveLength(1);
expect(messages.bob[0].message).toBe('hey bob'); expect(messages.bob[0].message).toBe('hey bob');
}); });
}); });

View file

@ -0,0 +1 @@
export const PROTOCOL_VERSION = 14;

View file

@ -0,0 +1,13 @@
import type { Enriched } from '@app/types';
let pendingOptions: Enriched.WebSocketConnectOptions | null = null;
export function setPendingOptions(options: Enriched.WebSocketConnectOptions) {
pendingOptions = options;
}
export function consumePendingOptions(): Enriched.WebSocketConnectOptions | null {
const opts = pendingOptions;
pendingOptions = null;
return opts;
}

View file

@ -1,35 +1,13 @@
import { WebClient } from '@app/websocket'; export { initWebClient } from './initWebClient';
import type { IWebClientRequest } from '@app/websocket';
export { createWebClientResponse } from './response'; export { createWebClientResponse } from './response';
export { createWebClientRequest } from './request'; export { createWebClientRequest } from './request';
import { createWebClientRequest } from './request';
/** /**
* UI-facing request surface. Each property is a lazy getter that resolves * UI-facing request surface. The request implementations are created once
* `WebClient.instance` at call time, so consumers can import this before the * at module load. They access `WebClient.instance` at call time (via lazy
* singleton is bootstrapped it only needs to exist by the first actual call. * internal references), so the singleton only needs to exist by the first
* * actual command send.
* Prefer this over importing `WebClient` directly: it keeps UI code free of
* transport-layer names and makes `@app/websocket` an internal detail of the
* `api` layer.
*/ */
export const request: IWebClientRequest = { export const request = createWebClientRequest();
get authentication() {
return WebClient.instance.request.authentication;
},
get session() {
return WebClient.instance.request.session;
},
get rooms() {
return WebClient.instance.request.rooms;
},
get admin() {
return WebClient.instance.request.admin;
},
get moderator() {
return WebClient.instance.request.moderator;
},
get game() {
return WebClient.instance.request.game;
},
};

View file

@ -0,0 +1,114 @@
import { App } from '@app/types';
import {
WebClient,
StatusEnum,
SessionEvents,
RoomEvents,
GameEvents,
SessionCommands,
generateSalt,
passwordSaltSupported,
} from '@app/websocket';
import type { WebClientConfig } from '@app/websocket';
import { createWebClientResponse } from './response';
import { consumePendingOptions } from './connectionState';
import { PROTOCOL_VERSION } from './config';
export function initWebClient(): void {
const response = createWebClientResponse();
const config: WebClientConfig = {
response,
onServerIdentified: (info) => {
const { serverName, serverVersion, protocolVersion, serverOptions } = info;
if (protocolVersion !== PROTOCOL_VERSION) {
SessionCommands.updateStatus(StatusEnum.DISCONNECTED, `Protocol version mismatch: ${protocolVersion}`);
SessionCommands.disconnect();
return;
}
const getPasswordSalt = passwordSaltSupported(serverOptions);
const options = consumePendingOptions();
if (!options) {
SessionCommands.updateStatus(StatusEnum.DISCONNECTED, 'Missing connection options');
SessionCommands.disconnect();
return;
}
switch (options.reason) {
case App.WebSocketConnectReason.LOGIN: {
const { password, ...rest } = options;
SessionCommands.updateStatus(StatusEnum.LOGGING_IN, 'Logging In...');
if (getPasswordSalt) {
SessionCommands.requestPasswordSalt(rest,
(salt) => SessionCommands.login(rest, password, salt),
() => {
response.session.loginFailed(); SessionCommands.disconnect();
},
);
} else {
SessionCommands.login(rest, password);
}
break;
}
case App.WebSocketConnectReason.REGISTER: {
const { password, ...rest } = options;
const passwordSalt = getPasswordSalt ? generateSalt() : null;
SessionCommands.register(rest, password, passwordSalt);
break;
}
case App.WebSocketConnectReason.ACTIVATE_ACCOUNT: {
const { password, ...rest } = options;
if (getPasswordSalt) {
SessionCommands.requestPasswordSalt(rest,
(salt) => SessionCommands.activate(rest, password, salt),
() => {
response.session.accountActivationFailed(); SessionCommands.disconnect();
},
);
} else {
SessionCommands.activate(rest, password);
}
break;
}
case App.WebSocketConnectReason.PASSWORD_RESET_REQUEST:
SessionCommands.forgotPasswordRequest(options);
break;
case App.WebSocketConnectReason.PASSWORD_RESET_CHALLENGE:
SessionCommands.forgotPasswordChallenge(options);
break;
case App.WebSocketConnectReason.PASSWORD_RESET: {
const { newPassword, ...rest } = options;
if (getPasswordSalt) {
SessionCommands.requestPasswordSalt(rest,
(salt) => SessionCommands.forgotPasswordReset(rest, newPassword, salt),
() => {
response.session.resetPasswordFailed(); SessionCommands.disconnect();
},
);
} else {
SessionCommands.forgotPasswordReset(rest, newPassword);
}
break;
}
default: {
SessionCommands.updateStatus(StatusEnum.DISCONNECTED, `Unknown Connection Reason: ${options.reason}`);
SessionCommands.disconnect();
break;
}
}
response.session.updateInfo(serverName, serverVersion);
},
sessionEvents: SessionEvents,
roomEvents: RoomEvents,
gameEvents: GameEvents,
keepAliveFn: (cb) => SessionCommands.ping(cb),
};
new WebClient(config);
}

View file

@ -1,34 +1,58 @@
import { App, Enriched } from '@app/types'; import { App, Enriched } from '@app/types';
import type { IAuthenticationRequest } from '@app/websocket'; import { WebClient, StatusEnum, SessionCommands } from '@app/websocket';
import { SessionCommands } from '@app/websocket'; import type { IAuthenticationRequest, AuthRequestMap } from '@app/websocket';
export class AuthenticationRequestImpl implements IAuthenticationRequest { import { setPendingOptions } from '../connectionState';
interface AppAuthRequestOverrides extends AuthRequestMap {
LoginParams: Omit<Enriched.LoginConnectOptions, 'reason'>;
ConnectTarget: Omit<Enriched.TestConnectionOptions, 'reason'>;
RegisterParams: Omit<Enriched.RegisterConnectOptions, 'reason'>;
ActivateParams: Omit<Enriched.ActivateConnectOptions, 'reason'>;
ForgotPasswordRequestParams: Omit<Enriched.PasswordResetRequestConnectOptions, 'reason'>;
ForgotPasswordChallengeParams: Omit<Enriched.PasswordResetChallengeConnectOptions, 'reason'>;
ForgotPasswordResetParams: Omit<Enriched.PasswordResetConnectOptions, 'reason'>;
}
export class AuthenticationRequestImpl implements IAuthenticationRequest<AppAuthRequestOverrides> {
login(options: Omit<Enriched.LoginConnectOptions, 'reason'>): void { login(options: Omit<Enriched.LoginConnectOptions, 'reason'>): void {
SessionCommands.connect({ ...options, reason: App.WebSocketConnectReason.LOGIN }); setPendingOptions({ ...options, reason: App.WebSocketConnectReason.LOGIN });
SessionCommands.updateStatus(StatusEnum.CONNECTING, 'Connecting...');
WebClient.instance.connect({ host: options.host, port: options.port });
} }
testConnection(options: Omit<Enriched.TestConnectionOptions, 'reason'>): void { testConnection(options: Omit<Enriched.TestConnectionOptions, 'reason'>): void {
SessionCommands.connect({ ...options, reason: App.WebSocketConnectReason.TEST_CONNECTION }); WebClient.instance.testConnect({ host: options.host, port: options.port });
} }
register(options: Omit<Enriched.RegisterConnectOptions, 'reason'>): void { register(options: Omit<Enriched.RegisterConnectOptions, 'reason'>): void {
SessionCommands.connect({ ...options, reason: App.WebSocketConnectReason.REGISTER }); setPendingOptions({ ...options, reason: App.WebSocketConnectReason.REGISTER });
SessionCommands.updateStatus(StatusEnum.CONNECTING, 'Connecting...');
WebClient.instance.connect({ host: options.host, port: options.port });
} }
activateAccount(options: Omit<Enriched.ActivateConnectOptions, 'reason'>): void { activateAccount(options: Omit<Enriched.ActivateConnectOptions, 'reason'>): void {
SessionCommands.connect({ ...options, reason: App.WebSocketConnectReason.ACTIVATE_ACCOUNT }); setPendingOptions({ ...options, reason: App.WebSocketConnectReason.ACTIVATE_ACCOUNT });
SessionCommands.updateStatus(StatusEnum.CONNECTING, 'Connecting...');
WebClient.instance.connect({ host: options.host, port: options.port });
} }
resetPasswordRequest(options: Omit<Enriched.PasswordResetRequestConnectOptions, 'reason'>): void { resetPasswordRequest(options: Omit<Enriched.PasswordResetRequestConnectOptions, 'reason'>): void {
SessionCommands.connect({ ...options, reason: App.WebSocketConnectReason.PASSWORD_RESET_REQUEST }); setPendingOptions({ ...options, reason: App.WebSocketConnectReason.PASSWORD_RESET_REQUEST });
SessionCommands.updateStatus(StatusEnum.CONNECTING, 'Connecting...');
WebClient.instance.connect({ host: options.host, port: options.port });
} }
resetPasswordChallenge(options: Omit<Enriched.PasswordResetChallengeConnectOptions, 'reason'>): void { resetPasswordChallenge(options: Omit<Enriched.PasswordResetChallengeConnectOptions, 'reason'>): void {
SessionCommands.connect({ ...options, reason: App.WebSocketConnectReason.PASSWORD_RESET_CHALLENGE }); setPendingOptions({ ...options, reason: App.WebSocketConnectReason.PASSWORD_RESET_CHALLENGE });
SessionCommands.updateStatus(StatusEnum.CONNECTING, 'Connecting...');
WebClient.instance.connect({ host: options.host, port: options.port });
} }
resetPassword(options: Omit<Enriched.PasswordResetConnectOptions, 'reason'>): void { resetPassword(options: Omit<Enriched.PasswordResetConnectOptions, 'reason'>): void {
SessionCommands.connect({ ...options, reason: App.WebSocketConnectReason.PASSWORD_RESET }); setPendingOptions({ ...options, reason: App.WebSocketConnectReason.PASSWORD_RESET });
SessionCommands.updateStatus(StatusEnum.CONNECTING, 'Connecting...');
WebClient.instance.connect({ host: options.host, port: options.port });
} }
disconnect(): void { disconnect(): void {

View file

@ -1,8 +1,10 @@
import { Data, Enriched } from '@app/types'; import { Data } from '@app/types';
import type { IRoomResponse } from '@app/websocket'; import type { IRoomResponse, WebSocketRoomResponseOverrides } from '@app/websocket';
import { RoomsDispatch } from '@app/store'; import { RoomsDispatch } from '@app/store';
export class RoomResponseImpl implements IRoomResponse { type Message = WebSocketRoomResponseOverrides['Event_RoomSay'];
export class RoomResponseImpl implements IRoomResponse<WebSocketRoomResponseOverrides> {
clearStore(): void { clearStore(): void {
RoomsDispatch.clearStore(); RoomsDispatch.clearStore();
} }
@ -23,7 +25,7 @@ export class RoomResponseImpl implements IRoomResponse {
RoomsDispatch.updateGames(roomId, gameList); RoomsDispatch.updateGames(roomId, gameList);
} }
addMessage(roomId: number, message: Enriched.Message): void { addMessage(roomId: number, message: Message): void {
RoomsDispatch.addMessage(roomId, message); RoomsDispatch.addMessage(roomId, message);
} }

View file

@ -1,8 +1,12 @@
import { App, Data, Enriched } from '@app/types'; import { Data } from '@app/types';
import type { ISessionResponse } from '@app/websocket'; import type { ISessionResponse, WebSocketSessionResponseOverrides } from '@app/websocket';
import { StatusEnum } from '@app/websocket';
import { GameDispatch, RoomsDispatch, ServerDispatch } from '@app/store'; import { GameDispatch, RoomsDispatch, ServerDispatch } from '@app/store';
export class SessionResponseImpl implements ISessionResponse { type LoginSuccess = WebSocketSessionResponseOverrides['Response_Login'];
type PendingActivation = WebSocketSessionResponseOverrides['Response'];
export class SessionResponseImpl implements ISessionResponse<WebSocketSessionResponseOverrides> {
initialized(): void { initialized(): void {
ServerDispatch.initialized(); ServerDispatch.initialized();
} }
@ -15,7 +19,7 @@ export class SessionResponseImpl implements ISessionResponse {
ServerDispatch.clearStore(); ServerDispatch.clearStore();
} }
loginSuccessful(options: Enriched.LoginSuccessContext): void { loginSuccessful(options: LoginSuccess): void {
ServerDispatch.loginSuccessful(options); ServerDispatch.loginSuccessful(options);
} }
@ -63,8 +67,8 @@ export class SessionResponseImpl implements ISessionResponse {
ServerDispatch.updateInfo(name, version); ServerDispatch.updateInfo(name, version);
} }
updateStatus(state: App.StatusEnum, description: string): void { updateStatus(state: StatusEnum, description: string): void {
if (state === App.StatusEnum.DISCONNECTED) { if (state === StatusEnum.DISCONNECTED) {
GameDispatch.clearStore(); GameDispatch.clearStore();
RoomsDispatch.clearStore(); RoomsDispatch.clearStore();
ServerDispatch.clearStore(); ServerDispatch.clearStore();
@ -92,7 +96,7 @@ export class SessionResponseImpl implements ISessionResponse {
ServerDispatch.serverMessage(message); ServerDispatch.serverMessage(message);
} }
accountAwaitingActivation(options: Enriched.PendingActivationContext): void { accountAwaitingActivation(options: PendingActivation): void {
ServerDispatch.accountAwaitingActivation(options); ServerDispatch.accountAwaitingActivation(options);
} }

View file

@ -1,5 +1,5 @@
// eslint-disable-next-line
import React, { useEffect, useMemo, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { NavLink, generatePath } from 'react-router-dom'; import { NavLink, generatePath } from 'react-router-dom';
@ -20,7 +20,7 @@ const ParsedMessage = ({ message }) => {
const [messageChunks, setMessageChunks] = useState(null); const [messageChunks, setMessageChunks] = useState(null);
const [name, setName] = useState(null); const [name, setName] = useState(null);
useMemo(() => { useEffect(() => {
const name = message.match(App.MESSAGE_SENDER_REGEX); const name = message.match(App.MESSAGE_SENDER_REGEX);
if (name) { if (name) {

View file

@ -7,27 +7,26 @@ import { createRoot } from 'react-dom/client';
import { StyledEngineProvider } from '@mui/material'; import { StyledEngineProvider } from '@mui/material';
import { ThemeProvider } from '@mui/material/styles'; import { ThemeProvider } from '@mui/material/styles';
import { WebClient } from '@app/websocket'; import { initWebClient } from '@app/api';
import { createWebClientResponse, createWebClientRequest } from '@app/api';
import { AppShell } from '@app/containers'; import { AppShell } from '@app/containers';
import { materialTheme } from './material-theme'; import { materialTheme } from './material-theme';
import './i18n'; import './i18n';
import './index.css'; import './index.css';
function initWebClient() { function useInitWebClient() {
const initialized = useRef(false); const initialized = useRef(false);
if (!initialized.current) { if (!initialized.current) {
initialized.current = true; initialized.current = true;
new WebClient(createWebClientResponse(), createWebClientRequest()); initWebClient();
} }
} }
const AppWithMaterialTheme = () => { const AppWithMaterialTheme = () => {
// Instantiate the WebClient singleton before any container renders or any // Instantiate the WebClient singleton before any container renders or any
// hook touches WebClient.instance. // hook touches WebClient.instance.
initWebClient(); useInitWebClient();
return ( return (
<StrictMode> <StrictMode>

View file

@ -1,17 +1,11 @@
export { StatusEnum } from '@app/websocket';
import type { StatusEnum } from '@app/websocket';
export interface ServerStatus { export interface ServerStatus {
status: StatusEnum; status: StatusEnum;
description: string; description: string;
} }
export enum StatusEnum {
DISCONNECTED,
CONNECTING,
CONNECTED,
LOGGING_IN,
LOGGED_IN,
DISCONNECTING = 99
}
export enum WebSocketConnectReason { export enum WebSocketConnectReason {
LOGIN, LOGIN,
REGISTER, REGISTER,

View file

@ -0,0 +1,8 @@
export enum StatusEnum {
DISCONNECTED,
CONNECTING,
CONNECTED,
LOGGING_IN,
LOGGED_IN,
DISCONNECTING = 99
}

View file

@ -1,6 +1,7 @@
const captured = vi.hoisted(() => ({ const captured = vi.hoisted(() => ({
wsOptions: null as WebSocketServiceConfig | null, wsOptions: null as WebSocketServiceConfig | null,
pbOptions: null as SocketTransport | null, pbOptions: null as SocketTransport | null,
pbEvents: null as EventRegistries | null,
})); }));
vi.mock('./services/WebSocketService', () => ({ vi.mock('./services/WebSocketService', () => ({
@ -17,8 +18,9 @@ vi.mock('./services/WebSocketService', () => ({
})); }));
vi.mock('./services/ProtobufService', () => ({ vi.mock('./services/ProtobufService', () => ({
ProtobufService: vi.fn().mockImplementation(function ProtobufServiceImpl(options: SocketTransport) { ProtobufService: vi.fn().mockImplementation(function ProtobufServiceImpl(transport: SocketTransport, events: EventRegistries) {
captured.pbOptions = options; captured.pbOptions = transport;
captured.pbEvents = events;
return { return {
handleMessageEvent: vi.fn(), handleMessageEvent: vi.fn(),
resetCommands: vi.fn(), resetCommands: vi.fn(),
@ -26,29 +28,28 @@ vi.mock('./services/ProtobufService', () => ({
}), }),
})); }));
vi.mock('./commands/session', () => ({
ping: vi.fn(),
}));
import { WebClient } from './WebClient'; import { WebClient } from './WebClient';
import { WebSocketService } from './services/WebSocketService'; import { WebSocketService } from './services/WebSocketService';
import { ProtobufService } from './services/ProtobufService'; import { ProtobufService } from './services/ProtobufService';
import { ping } from './commands/session'; import { StatusEnum } from './StatusEnum';
import { App, Enriched } from '@app/types';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
import { Mock } from 'vitest'; import { Mock } from 'vitest';
import { SocketTransport } from './services/ProtobufService'; import { SocketTransport, EventRegistries } from './services/ProtobufService';
import { WebSocketServiceConfig } from './services/WebSocketService'; import { WebSocketServiceConfig } from './services/WebSocketService';
import type { IWebClientResponse, IWebClientRequest } from './interfaces'; import type { IWebClientResponse } from './interfaces';
import { installMockWebSocket } from './__mocks__/helpers'; import type { WebClientConfig, ConnectTarget } from './WebClientConfig';
import { installMockWebSocket, useWebClientCleanup } from './__mocks__/helpers';
function makeMockResponse(): IWebClientResponse { function makeMockResponse(): IWebClientResponse {
return { return {
session: { session: {
initialized: vi.fn(), initialized: vi.fn(),
connectionAttempted: vi.fn(), connectionAttempted: vi.fn(),
connectionFailed: vi.fn(),
clearStore: vi.fn(), clearStore: vi.fn(),
updateStatus: vi.fn(), updateStatus: vi.fn(),
testConnectionSuccessful: vi.fn(),
testConnectionFailed: vi.fn(),
}, },
room: { clearStore: vi.fn() }, room: { clearStore: vi.fn() },
game: { clearStore: vi.fn() }, game: { clearStore: vi.fn() },
@ -57,28 +58,35 @@ function makeMockResponse(): IWebClientResponse {
} as unknown as IWebClientResponse; } as unknown as IWebClientResponse;
} }
function makeMockRequest(): IWebClientRequest { function makeMockConfig(response: IWebClientResponse): WebClientConfig {
return { return {
authentication: {}, response,
session: {}, onServerIdentified: vi.fn(),
rooms: {}, sessionEvents: [],
admin: {}, roomEvents: [],
moderator: {}, gameEvents: [],
} as unknown as IWebClientRequest; keepAliveFn: vi.fn(),
};
} }
useWebClientCleanup();
describe('WebClient', () => { describe('WebClient', () => {
let client: WebClient; let client: WebClient;
let mockResponse: IWebClientResponse; let mockResponse: IWebClientResponse;
let mockRequest: IWebClientRequest; let mockConfig: WebClientConfig;
let messageSubject: Subject<MessageEvent>; let messageSubject: Subject<MessageEvent>;
beforeEach(() => { beforeEach(() => {
// Reset the singleton so each test starts fresh. // Reset the singleton so each test starts fresh.
// This direct reset is needed in addition to useWebClientCleanup() because
// this file imports the real WebClient (not a mock), and with isolate:false
// the helper's import may resolve to a different (mocked) module reference.
(WebClient as unknown as { _instance: WebClient | null })._instance = null; (WebClient as unknown as { _instance: WebClient | null })._instance = null;
(ProtobufService as Mock).mockImplementation(function ProtobufServiceImpl(options: SocketTransport) { (ProtobufService as Mock).mockImplementation(function ProtobufServiceImpl(transport: SocketTransport, events: EventRegistries) {
captured.pbOptions = options; captured.pbOptions = transport;
captured.pbEvents = events;
return { return {
handleMessageEvent: vi.fn(), handleMessageEvent: vi.fn(),
resetCommands: vi.fn(), resetCommands: vi.fn(),
@ -98,8 +106,8 @@ describe('WebClient', () => {
vi.spyOn(console, 'log').mockImplementation(() => {}); vi.spyOn(console, 'log').mockImplementation(() => {});
mockResponse = makeMockResponse(); mockResponse = makeMockResponse();
mockRequest = makeMockRequest(); mockConfig = makeMockConfig(mockResponse);
client = new WebClient(mockResponse, mockRequest); client = new WebClient(mockConfig);
}); });
afterEach(() => { afterEach(() => {
@ -108,9 +116,9 @@ describe('WebClient', () => {
}); });
describe('constructor', () => { describe('constructor', () => {
it('stores the response and request on the instance', () => { it('stores the response and config on the instance', () => {
expect(client.response).toBe(mockResponse); expect(client.response).toBe(mockResponse);
expect(client.request).toBe(mockRequest); expect(client.config).toBe(mockConfig);
}); });
it('subscribes socket.message$ to protobuf.handleMessageEvent', () => { it('subscribes socket.message$ to protobuf.handleMessageEvent', () => {
@ -128,7 +136,7 @@ describe('WebClient', () => {
}); });
it('throws when instantiated more than once', () => { it('throws when instantiated more than once', () => {
expect(() => new WebClient(makeMockResponse(), makeMockRequest())).toThrow(/singleton/); expect(() => new WebClient(makeMockConfig(makeMockResponse()))).toThrow(/singleton/);
}); });
}); });
@ -141,16 +149,15 @@ describe('WebClient', () => {
describe('connect', () => { describe('connect', () => {
it('calls response.session.connectionAttempted', () => { it('calls response.session.connectionAttempted', () => {
const opts: Enriched.WebSocketConnectOptions = { host: 'h', port: '1', reason: App.WebSocketConnectReason.LOGIN, userName: 'u' }; const target: ConnectTarget = { host: 'h', port: '1' };
client.connect(opts); client.connect(target);
expect(mockResponse.session.connectionAttempted).toHaveBeenCalled(); expect(mockResponse.session.connectionAttempted).toHaveBeenCalled();
}); });
it('stores options and calls socket.connect', () => { it('calls socket.connect with target', () => {
const opts: Enriched.WebSocketConnectOptions = { host: 'h', port: '1', reason: App.WebSocketConnectReason.LOGIN, userName: 'u' }; const target: ConnectTarget = { host: 'h', port: '1' };
client.connect(opts); client.connect(target);
expect(client.options).toBe(opts); expect(client.socket.connect).toHaveBeenCalledWith(target);
expect(client.socket.connect).toHaveBeenCalledWith(opts);
}); });
}); });
@ -172,30 +179,28 @@ describe('WebClient', () => {
vi.useRealTimers(); vi.useRealTimers();
}); });
const opts: Enriched.WebSocketConnectOptions = { host: 'h', port: '1', reason: App.WebSocketConnectReason.LOGIN, userName: 'u' }; const target: ConnectTarget = { host: 'h', port: '1' };
it('creates a WebSocket with the correct URL', () => { it('creates a WebSocket with the correct URL', () => {
client.testConnect(opts); client.testConnect(target);
expect(MockWS).toHaveBeenCalledWith(expect.stringContaining('://h:1')); expect(MockWS).toHaveBeenCalledWith(expect.stringContaining('://h:1'));
}); });
it('calls testConnectionSuccessful and closes on open', () => { it('calls testConnectionSuccessful and closes on open', () => {
(mockResponse.session as any).testConnectionSuccessful = vi.fn(); client.testConnect(target);
client.testConnect(opts);
wsMockInstance.onopen(); wsMockInstance.onopen();
expect((mockResponse.session as any).testConnectionSuccessful).toHaveBeenCalled(); expect(mockResponse.session.testConnectionSuccessful).toHaveBeenCalled();
expect(wsMockInstance.close).toHaveBeenCalled(); expect(wsMockInstance.close).toHaveBeenCalled();
}); });
it('calls testConnectionFailed on error', () => { it('calls testConnectionFailed on error', () => {
(mockResponse.session as any).testConnectionFailed = vi.fn(); client.testConnect(target);
client.testConnect(opts);
wsMockInstance.onerror(); wsMockInstance.onerror();
expect((mockResponse.session as any).testConnectionFailed).toHaveBeenCalled(); expect(mockResponse.session.testConnectionFailed).toHaveBeenCalled();
}); });
it('closes socket after keepalive timeout', () => { it('closes socket after keepalive timeout', () => {
client.testConnect(opts); client.testConnect(target);
vi.advanceTimersByTime(5000); vi.advanceTimersByTime(5000);
expect(wsMockInstance.close).toHaveBeenCalled(); expect(wsMockInstance.close).toHaveBeenCalled();
}); });
@ -210,32 +215,43 @@ describe('WebClient', () => {
describe('updateStatus', () => { describe('updateStatus', () => {
it('sets the status', () => { it('sets the status', () => {
client.updateStatus(App.StatusEnum.CONNECTED); client.updateStatus(StatusEnum.CONNECTED);
expect(client.status).toBe(App.StatusEnum.CONNECTED); expect(client.status).toBe(StatusEnum.CONNECTED);
}); });
it('calls protobuf.resetCommands on DISCONNECTED', () => { it('calls protobuf.resetCommands on DISCONNECTED', () => {
client.updateStatus(App.StatusEnum.DISCONNECTED); client.updateStatus(StatusEnum.DISCONNECTED);
expect(client.protobuf.resetCommands).toHaveBeenCalled(); expect(client.protobuf.resetCommands).toHaveBeenCalled();
}); });
it('does not reset protobuf when status is not DISCONNECTED', () => { it('does not reset protobuf when status is not DISCONNECTED', () => {
client.updateStatus(App.StatusEnum.CONNECTED); client.updateStatus(StatusEnum.CONNECTED);
expect(client.protobuf.resetCommands).not.toHaveBeenCalled(); expect(client.protobuf.resetCommands).not.toHaveBeenCalled();
}); });
}); });
describe('constructor closures', () => { describe('constructor closures', () => {
it('keepAliveFn calls ping with the callback', () => { it('keepAliveFn forwards from config to WebSocketService', () => {
const cb = vi.fn(); const cb = vi.fn();
captured.wsOptions!.keepAliveFn(cb); captured.wsOptions!.keepAliveFn(cb);
expect(ping).toHaveBeenCalledWith(cb); expect(mockConfig.keepAliveFn).toHaveBeenCalledWith(cb);
}); });
it('onStatusChange routes to response.session.updateStatus and updates own status', () => { it('onStatusChange routes to response.session.updateStatus and updates own status', () => {
captured.wsOptions!.onStatusChange(App.StatusEnum.CONNECTED, 'Connected'); captured.wsOptions!.onStatusChange(StatusEnum.CONNECTED, 'Connected');
expect(mockResponse.session.updateStatus).toHaveBeenCalledWith(App.StatusEnum.CONNECTED, 'Connected'); expect(mockResponse.session.updateStatus).toHaveBeenCalledWith(StatusEnum.CONNECTED, 'Connected');
expect(client.status).toBe(App.StatusEnum.CONNECTED); expect(client.status).toBe(StatusEnum.CONNECTED);
});
it('onConnectionFailed routes to response.session.connectionFailed', () => {
captured.wsOptions!.onConnectionFailed();
expect(mockResponse.session.connectionFailed).toHaveBeenCalled();
});
it('passes event registries from config to ProtobufService', () => {
expect(captured.pbEvents!.sessionEvents).toBe(mockConfig.sessionEvents);
expect(captured.pbEvents!.roomEvents).toBe(mockConfig.roomEvents);
expect(captured.pbEvents!.gameEvents).toBe(mockConfig.gameEvents);
}); });
it('send closure delegates to socket.send', () => { it('send closure delegates to socket.send', () => {

View file

@ -1,10 +1,9 @@
import { App, Enriched } from '@app/types'; import { StatusEnum } from './StatusEnum';
import { ProtobufService } from './services/ProtobufService'; import { ProtobufService } from './services/ProtobufService';
import { WebSocketService } from './services/WebSocketService'; import { WebSocketService } from './services/WebSocketService';
import { ping } from './commands/session';
import { CLIENT_OPTIONS } from './config'; import { CLIENT_OPTIONS } from './config';
import { IWebClientResponse, IWebClientRequest } from './interfaces'; import type { IWebClientResponse } from './interfaces';
import type { WebClientConfig, ConnectTarget } from './WebClientConfig';
export class WebClient { export class WebClient {
private static _instance: WebClient | null = null; private static _instance: WebClient | null = null;
@ -12,7 +11,7 @@ export class WebClient {
public static get instance(): WebClient { public static get instance(): WebClient {
if (!WebClient._instance) { if (!WebClient._instance) {
throw new Error( throw new Error(
'WebClient has not been initialized. Instantiate it via `new WebClient(response, request)` before accessing `WebClient.instance`.' 'WebClient has not been initialized. Instantiate it via `new WebClient(config)` before accessing `WebClient.instance`.'
); );
} }
return WebClient._instance; return WebClient._instance;
@ -21,32 +20,40 @@ export class WebClient {
public socket: WebSocketService; public socket: WebSocketService;
public protobuf: ProtobufService; public protobuf: ProtobufService;
public response: IWebClientResponse; public response: IWebClientResponse;
public request: IWebClientRequest; public config: WebClientConfig;
public options: Enriched.WebSocketConnectOptions | null = null; public status: StatusEnum;
public status: App.StatusEnum;
constructor(response: IWebClientResponse, request: IWebClientRequest) { constructor(config: WebClientConfig) {
if (WebClient._instance) { if (WebClient._instance) {
throw new Error('WebClient is a singleton and has already been initialized.'); throw new Error('WebClient is a singleton and has already been initialized.');
} }
this.response = response; this.config = config;
this.request = request; this.response = config.response;
this.socket = new WebSocketService({ this.socket = new WebSocketService({
keepAliveFn: (cb) => ping(cb), keepAliveFn: config.keepAliveFn,
response,
onStatusChange: (status, description) => { onStatusChange: (status, description) => {
this.response.session.updateStatus(status, description); this.response.session.updateStatus(status, description);
this.updateStatus(status); this.updateStatus(status);
}, },
onConnectionFailed: () => {
this.response.session.connectionFailed();
},
}); });
this.protobuf = new ProtobufService({ this.protobuf = new ProtobufService(
send: (data) => this.socket.send(data), {
isOpen: () => this.socket.checkReadyState(WebSocket.OPEN), send: (data) => this.socket.send(data),
}); isOpen: () => this.socket.checkReadyState(WebSocket.OPEN),
},
{
sessionEvents: config.sessionEvents,
roomEvents: config.roomEvents,
gameEvents: config.gameEvents,
},
);
this.socket.message$.subscribe((message: MessageEvent) => { this.socket.message$.subscribe((message: MessageEvent) => {
this.protobuf.handleMessageEvent(message); this.protobuf.handleMessageEvent(message);
@ -57,15 +64,14 @@ export class WebClient {
this.response.session.initialized(); this.response.session.initialized();
} }
public connect(options: Enriched.WebSocketConnectOptions) { public connect(target: ConnectTarget) {
this.response.session.connectionAttempted(); this.response.session.connectionAttempted();
this.options = options; this.socket.connect(target);
this.socket.connect(options);
} }
public testConnect(options: Enriched.WebSocketConnectOptions) { public testConnect(target: ConnectTarget) {
const protocol = window.location.hostname === 'localhost' ? 'ws' : 'wss'; const protocol = window.location.hostname === 'localhost' ? 'ws' : 'wss';
const { host, port } = options; const { host, port } = target;
const socket = new WebSocket(`${protocol}://${host}:${port}`); const socket = new WebSocket(`${protocol}://${host}:${port}`);
socket.binaryType = 'arraybuffer'; socket.binaryType = 'arraybuffer';
@ -88,10 +94,10 @@ export class WebClient {
this.socket.disconnect(); this.socket.disconnect();
} }
public updateStatus(status: App.StatusEnum) { public updateStatus(status: StatusEnum) {
this.status = status; this.status = status;
if (status === App.StatusEnum.DISCONNECTED) { if (status === StatusEnum.DISCONNECTED) {
this.protobuf.resetCommands(); this.protobuf.resetCommands();
} }
} }

View file

@ -0,0 +1,27 @@
import type {
RegistryEntry,
SessionEvent,
RoomEvent,
GameEvent,
Event_ServerIdentification,
} from '@app/generated';
import type { GameEventMeta } from './types';
import type { IWebClientResponse } from './interfaces';
export interface ConnectTarget {
host: string;
port: string;
}
export interface WebClientConfig {
response: IWebClientResponse;
onServerIdentified(info: Event_ServerIdentification): void;
sessionEvents: RegistryEntry<unknown, SessionEvent>[];
roomEvents: RegistryEntry<unknown, RoomEvent, RoomEvent>[];
gameEvents: RegistryEntry<unknown, GameEvent, GameEventMeta>[];
keepAliveFn(pingReceived: () => void): void;
}

View file

@ -1,8 +1,27 @@
/** /**
* Shared mock factories for websocket layer unit tests. * Shared mock factories for websocket layer unit tests.
* Import the helpers you need in each spec file via: * Import the helpers you need in each spec file via:
* import { makeMockWebSocket } from '../__mocks__/helpers'; * import { makeMockWebSocket, useWebClientCleanup } from '../__mocks__/helpers';
*/ */
import { WebClient } from '../WebClient';
/**
* Resets the WebClient singleton to null. Call directly, or use
* `useWebClientCleanup()` to register automatic beforeEach/afterEach hooks.
*/
export function resetWebClientSingleton() {
(WebClient as unknown as { _instance: WebClient | null })._instance = null;
}
/**
* Registers beforeEach/afterEach hooks that reset the WebClient singleton.
* Call at describe-level or file-level in any spec that mocks WebClient.
* Prevents isolate:false singleton leakage between spec files.
*/
export function useWebClientCleanup() {
beforeEach(() => resetWebClientSingleton());
afterEach(() => resetWebClientSingleton());
}
/** Builds a mock WebSocket instance */ /** Builds a mock WebSocket instance */
export function makeMockWebSocketInstance() { export function makeMockWebSocketInstance() {

View file

@ -16,7 +16,7 @@ export function makeWebClientMock() {
testConnect: vi.fn(), testConnect: vi.fn(),
disconnect: vi.fn(), disconnect: vi.fn(),
updateStatus: vi.fn(), updateStatus: vi.fn(),
options: {}, config: { onServerIdentified: vi.fn() },
status: 0, status: 0,
protobuf: { protobuf: {
sendSessionCommand: vi.fn(), sendSessionCommand: vi.fn(),

View file

@ -1,10 +1,10 @@
import { create } from '@bufbuild/protobuf'; import { create } from '@bufbuild/protobuf';
import { Data } from '@app/types'; import { Command_AdjustMod_ext, Command_AdjustModSchema } from '@app/generated';
import { WebClient } from '../../WebClient'; import { WebClient } from '../../WebClient';
export function adjustMod(userName: string, shouldBeMod?: boolean, shouldBeJudge?: boolean): void { export function adjustMod(userName: string, shouldBeMod?: boolean, shouldBeJudge?: boolean): void {
WebClient.instance.protobuf.sendAdminCommand( WebClient.instance.protobuf.sendAdminCommand(
Data.Command_AdjustMod_ext, Command_AdjustMod_ext,
create(Data.Command_AdjustModSchema, { userName, shouldBeMod, shouldBeJudge }), create(Command_AdjustModSchema, { userName, shouldBeMod, shouldBeJudge }),
{ {
onSuccess: () => { onSuccess: () => {
WebClient.instance.response.admin.adjustMod(userName, shouldBeMod, shouldBeJudge); WebClient.instance.response.admin.adjustMod(userName, shouldBeMod, shouldBeJudge);

View file

@ -15,6 +15,7 @@ vi.mock('../../WebClient', () => ({
})); }));
import { makeCallbackHelpers } from '../../__mocks__/callbackHelpers'; import { makeCallbackHelpers } from '../../__mocks__/callbackHelpers';
import { useWebClientCleanup } from '../../__mocks__/helpers';
import { WebClient } from '../../WebClient'; import { WebClient } from '../../WebClient';
import { adjustMod } from './adjustMod'; import { adjustMod } from './adjustMod';
import { reloadConfig } from './reloadConfig'; import { reloadConfig } from './reloadConfig';
@ -23,6 +24,8 @@ import { updateServerMessage } from './updateServerMessage';
import { Mock } from 'vitest'; import { Mock } from 'vitest';
useWebClientCleanup();
const { invokeOnSuccess } = makeCallbackHelpers( const { invokeOnSuccess } = makeCallbackHelpers(
WebClient.instance.protobuf.sendAdminCommand as Mock, WebClient.instance.protobuf.sendAdminCommand as Mock,
2 2

View file

@ -1,8 +1,8 @@
import { create } from '@bufbuild/protobuf'; import { create } from '@bufbuild/protobuf';
import { Data } from '@app/types'; import { Command_ReloadConfig_ext, Command_ReloadConfigSchema } from '@app/generated';
import { WebClient } from '../../WebClient'; import { WebClient } from '../../WebClient';
export function reloadConfig(): void { export function reloadConfig(): void {
WebClient.instance.protobuf.sendAdminCommand(Data.Command_ReloadConfig_ext, create(Data.Command_ReloadConfigSchema), { WebClient.instance.protobuf.sendAdminCommand(Command_ReloadConfig_ext, create(Command_ReloadConfigSchema), {
onSuccess: () => { onSuccess: () => {
WebClient.instance.response.admin.reloadConfig(); WebClient.instance.response.admin.reloadConfig();
}, },

View file

@ -1,10 +1,10 @@
import { create } from '@bufbuild/protobuf'; import { create } from '@bufbuild/protobuf';
import { Data } from '@app/types'; import { Command_ShutdownServer_ext, Command_ShutdownServerSchema } from '@app/generated';
import { WebClient } from '../../WebClient'; import { WebClient } from '../../WebClient';
export function shutdownServer(reason: string, minutes: number): void { export function shutdownServer(reason: string, minutes: number): void {
WebClient.instance.protobuf.sendAdminCommand( WebClient.instance.protobuf.sendAdminCommand(
Data.Command_ShutdownServer_ext, Command_ShutdownServer_ext,
create(Data.Command_ShutdownServerSchema, { reason, minutes }), create(Command_ShutdownServerSchema, { reason, minutes }),
{ {
onSuccess: () => { onSuccess: () => {
WebClient.instance.response.admin.shutdownServer(); WebClient.instance.response.admin.shutdownServer();

View file

@ -1,8 +1,8 @@
import { create } from '@bufbuild/protobuf'; import { create } from '@bufbuild/protobuf';
import { Data } from '@app/types'; import { Command_UpdateServerMessage_ext, Command_UpdateServerMessageSchema } from '@app/generated';
import { WebClient } from '../../WebClient'; import { WebClient } from '../../WebClient';
export function updateServerMessage(): void { export function updateServerMessage(): void {
WebClient.instance.protobuf.sendAdminCommand(Data.Command_UpdateServerMessage_ext, create(Data.Command_UpdateServerMessageSchema), { WebClient.instance.protobuf.sendAdminCommand(Command_UpdateServerMessage_ext, create(Command_UpdateServerMessageSchema), {
onSuccess: () => { onSuccess: () => {
WebClient.instance.response.admin.updateServerMessage(); WebClient.instance.response.admin.updateServerMessage();
}, },

View file

@ -1,8 +1,7 @@
import { create } from '@bufbuild/protobuf'; import { create } from '@bufbuild/protobuf';
import { Command_AttachCard_ext, Command_AttachCardSchema, type AttachCardParams } from '@app/generated';
import { WebClient } from '../../WebClient'; import { WebClient } from '../../WebClient';
import { Data } from '@app/types'; export function attachCard(gameId: number, params: AttachCardParams): void {
WebClient.instance.protobuf.sendGameCommand(gameId, Command_AttachCard_ext, create(Command_AttachCardSchema, params));
export function attachCard(gameId: number, params: Data.AttachCardParams): void {
WebClient.instance.protobuf.sendGameCommand(gameId, Data.Command_AttachCard_ext, create(Data.Command_AttachCardSchema, params));
} }

View file

@ -1,12 +1,11 @@
import { create } from '@bufbuild/protobuf'; import { create } from '@bufbuild/protobuf';
import { Command_ChangeZoneProperties_ext, Command_ChangeZonePropertiesSchema, type ChangeZonePropertiesParams } from '@app/generated';
import { WebClient } from '../../WebClient'; import { WebClient } from '../../WebClient';
import { Data } from '@app/types'; export function changeZoneProperties(gameId: number, params: ChangeZonePropertiesParams): void {
export function changeZoneProperties(gameId: number, params: Data.ChangeZonePropertiesParams): void {
WebClient.instance.protobuf.sendGameCommand( WebClient.instance.protobuf.sendGameCommand(
gameId, gameId,
Data.Command_ChangeZoneProperties_ext, Command_ChangeZoneProperties_ext,
create(Data.Command_ChangeZonePropertiesSchema, params) create(Command_ChangeZonePropertiesSchema, params)
); );
} }

View file

@ -1,7 +1,7 @@
import { create } from '@bufbuild/protobuf'; import { create } from '@bufbuild/protobuf';
import { Command_Concede_ext, Command_ConcedeSchema } from '@app/generated';
import { WebClient } from '../../WebClient'; import { WebClient } from '../../WebClient';
import { Data } from '@app/types';
export function concede(gameId: number): void { export function concede(gameId: number): void {
WebClient.instance.protobuf.sendGameCommand(gameId, Data.Command_Concede_ext, create(Data.Command_ConcedeSchema)); WebClient.instance.protobuf.sendGameCommand(gameId, Command_Concede_ext, create(Command_ConcedeSchema));
} }

View file

@ -1,8 +1,7 @@
import { create } from '@bufbuild/protobuf'; import { create } from '@bufbuild/protobuf';
import { Command_CreateArrow_ext, Command_CreateArrowSchema, type CreateArrowParams } from '@app/generated';
import { WebClient } from '../../WebClient'; import { WebClient } from '../../WebClient';
import { Data } from '@app/types'; export function createArrow(gameId: number, params: CreateArrowParams): void {
WebClient.instance.protobuf.sendGameCommand(gameId, Command_CreateArrow_ext, create(Command_CreateArrowSchema, params));
export function createArrow(gameId: number, params: Data.CreateArrowParams): void {
WebClient.instance.protobuf.sendGameCommand(gameId, Data.Command_CreateArrow_ext, create(Data.Command_CreateArrowSchema, params));
} }

View file

@ -1,8 +1,7 @@
import { create } from '@bufbuild/protobuf'; import { create } from '@bufbuild/protobuf';
import { Command_CreateCounter_ext, Command_CreateCounterSchema, type CreateCounterParams } from '@app/generated';
import { WebClient } from '../../WebClient'; import { WebClient } from '../../WebClient';
import { Data } from '@app/types'; export function createCounter(gameId: number, params: CreateCounterParams): void {
WebClient.instance.protobuf.sendGameCommand(gameId, Command_CreateCounter_ext, create(Command_CreateCounterSchema, params));
export function createCounter(gameId: number, params: Data.CreateCounterParams): void {
WebClient.instance.protobuf.sendGameCommand(gameId, Data.Command_CreateCounter_ext, create(Data.Command_CreateCounterSchema, params));
} }

View file

@ -1,8 +1,7 @@
import { create } from '@bufbuild/protobuf'; import { create } from '@bufbuild/protobuf';
import { Command_CreateToken_ext, Command_CreateTokenSchema, type CreateTokenParams } from '@app/generated';
import { WebClient } from '../../WebClient'; import { WebClient } from '../../WebClient';
import { Data } from '@app/types'; export function createToken(gameId: number, params: CreateTokenParams): void {
WebClient.instance.protobuf.sendGameCommand(gameId, Command_CreateToken_ext, create(Command_CreateTokenSchema, params));
export function createToken(gameId: number, params: Data.CreateTokenParams): void {
WebClient.instance.protobuf.sendGameCommand(gameId, Data.Command_CreateToken_ext, create(Data.Command_CreateTokenSchema, params));
} }

View file

@ -1,8 +1,7 @@
import { create } from '@bufbuild/protobuf'; import { create } from '@bufbuild/protobuf';
import { Command_DeckSelect_ext, Command_DeckSelectSchema, type DeckSelectParams } from '@app/generated';
import { WebClient } from '../../WebClient'; import { WebClient } from '../../WebClient';
import { Data } from '@app/types'; export function deckSelect(gameId: number, params: DeckSelectParams): void {
WebClient.instance.protobuf.sendGameCommand(gameId, Command_DeckSelect_ext, create(Command_DeckSelectSchema, params));
export function deckSelect(gameId: number, params: Data.DeckSelectParams): void {
WebClient.instance.protobuf.sendGameCommand(gameId, Data.Command_DeckSelect_ext, create(Data.Command_DeckSelectSchema, params));
} }

View file

@ -1,8 +1,7 @@
import { create } from '@bufbuild/protobuf'; import { create } from '@bufbuild/protobuf';
import { Command_DelCounter_ext, Command_DelCounterSchema, type DelCounterParams } from '@app/generated';
import { WebClient } from '../../WebClient'; import { WebClient } from '../../WebClient';
import { Data } from '@app/types'; export function delCounter(gameId: number, params: DelCounterParams): void {
WebClient.instance.protobuf.sendGameCommand(gameId, Command_DelCounter_ext, create(Command_DelCounterSchema, params));
export function delCounter(gameId: number, params: Data.DelCounterParams): void {
WebClient.instance.protobuf.sendGameCommand(gameId, Data.Command_DelCounter_ext, create(Data.Command_DelCounterSchema, params));
} }

View file

@ -1,8 +1,7 @@
import { create } from '@bufbuild/protobuf'; import { create } from '@bufbuild/protobuf';
import { Command_DeleteArrow_ext, Command_DeleteArrowSchema, type DeleteArrowParams } from '@app/generated';
import { WebClient } from '../../WebClient'; import { WebClient } from '../../WebClient';
import { Data } from '@app/types'; export function deleteArrow(gameId: number, params: DeleteArrowParams): void {
WebClient.instance.protobuf.sendGameCommand(gameId, Command_DeleteArrow_ext, create(Command_DeleteArrowSchema, params));
export function deleteArrow(gameId: number, params: Data.DeleteArrowParams): void {
WebClient.instance.protobuf.sendGameCommand(gameId, Data.Command_DeleteArrow_ext, create(Data.Command_DeleteArrowSchema, params));
} }

View file

@ -1,8 +1,7 @@
import { create } from '@bufbuild/protobuf'; import { create } from '@bufbuild/protobuf';
import { Command_DrawCards_ext, Command_DrawCardsSchema, type DrawCardsParams } from '@app/generated';
import { WebClient } from '../../WebClient'; import { WebClient } from '../../WebClient';
import { Data } from '@app/types'; export function drawCards(gameId: number, params: DrawCardsParams): void {
WebClient.instance.protobuf.sendGameCommand(gameId, Command_DrawCards_ext, create(Command_DrawCardsSchema, params));
export function drawCards(gameId: number, params: Data.DrawCardsParams): void {
WebClient.instance.protobuf.sendGameCommand(gameId, Data.Command_DrawCards_ext, create(Data.Command_DrawCardsSchema, params));
} }

View file

@ -1,8 +1,7 @@
import { create } from '@bufbuild/protobuf'; import { create } from '@bufbuild/protobuf';
import { Command_DumpZone_ext, Command_DumpZoneSchema, type DumpZoneParams } from '@app/generated';
import { WebClient } from '../../WebClient'; import { WebClient } from '../../WebClient';
import { Data } from '@app/types'; export function dumpZone(gameId: number, params: DumpZoneParams): void {
WebClient.instance.protobuf.sendGameCommand(gameId, Command_DumpZone_ext, create(Command_DumpZoneSchema, params));
export function dumpZone(gameId: number, params: Data.DumpZoneParams): void {
WebClient.instance.protobuf.sendGameCommand(gameId, Data.Command_DumpZone_ext, create(Data.Command_DumpZoneSchema, params));
} }

View file

@ -1,8 +1,7 @@
import { create } from '@bufbuild/protobuf'; import { create } from '@bufbuild/protobuf';
import { Command_FlipCard_ext, Command_FlipCardSchema, type FlipCardParams } from '@app/generated';
import { WebClient } from '../../WebClient'; import { WebClient } from '../../WebClient';
import { Data } from '@app/types'; export function flipCard(gameId: number, params: FlipCardParams): void {
WebClient.instance.protobuf.sendGameCommand(gameId, Command_FlipCard_ext, create(Command_FlipCardSchema, params));
export function flipCard(gameId: number, params: Data.FlipCardParams): void {
WebClient.instance.protobuf.sendGameCommand(gameId, Data.Command_FlipCard_ext, create(Data.Command_FlipCardSchema, params));
} }

View file

@ -8,8 +8,48 @@ vi.mock('../../WebClient', () => ({
})); }));
import { WebClient } from '../../WebClient'; import { WebClient } from '../../WebClient';
import { useWebClientCleanup } from '../../__mocks__/helpers';
useWebClientCleanup();
import { create, setExtension } from '@bufbuild/protobuf'; import { create, setExtension } from '@bufbuild/protobuf';
import { Data } from '@app/types'; import {
Command_AttachCard_ext,
Command_ChangeZoneProperties_ext,
Command_Concede_ext,
Command_CreateArrow_ext,
Command_CreateCounter_ext,
Command_CreateToken_ext,
Command_DeckSelect_ext,
Command_DelCounter_ext,
Command_DeleteArrow_ext,
Command_DrawCards_ext,
Command_DrawCardsSchema,
Command_DumpZone_ext,
Command_FlipCard_ext,
Command_GameSay_ext,
Command_IncCardCounter_ext,
Command_IncCounter_ext,
Command_Judge_ext,
Command_KickFromGame_ext,
Command_LeaveGame_ext,
Command_MoveCard_ext,
Command_Mulligan_ext,
Command_NextTurn_ext,
Command_ReadyStart_ext,
Command_RevealCards_ext,
Command_ReverseTurn_ext,
Command_RollDie_ext,
Command_SetActivePhase_ext,
Command_SetCardAttr_ext,
Command_SetCardCounter_ext,
Command_SetCounter_ext,
Command_SetSideboardLock_ext,
Command_SetSideboardPlan_ext,
Command_Shuffle_ext,
Command_UndoDraw_ext,
Command_Unconcede_ext,
GameCommandSchema,
} from '@app/generated';
import { attachCard } from './attachCard'; import { attachCard } from './attachCard';
import { changeZoneProperties } from './changeZoneProperties'; import { changeZoneProperties } from './changeZoneProperties';
@ -52,122 +92,122 @@ describe('Game commands — delegate to WebClient.instance.protobuf.sendGameComm
it('attachCard sends Command_AttachCard', () => { it('attachCard sends Command_AttachCard', () => {
attachCard(gameId, { cardId: 10, startZone: 'hand' }); attachCard(gameId, { cardId: 10, startZone: 'hand' });
expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith( expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith(
gameId, Data.Command_AttachCard_ext, expect.objectContaining({ cardId: 10, startZone: 'hand' }) gameId, Command_AttachCard_ext, expect.objectContaining({ cardId: 10, startZone: 'hand' })
); );
}); });
it('changeZoneProperties sends Command_ChangeZoneProperties', () => { it('changeZoneProperties sends Command_ChangeZoneProperties', () => {
changeZoneProperties(gameId, { zoneName: 'side' }); changeZoneProperties(gameId, { zoneName: 'side' });
expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith( expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith(
gameId, Data.Command_ChangeZoneProperties_ext, expect.objectContaining({ zoneName: 'side' }) gameId, Command_ChangeZoneProperties_ext, expect.objectContaining({ zoneName: 'side' })
); );
}); });
it('concede sends Command_Concede with empty object', () => { it('concede sends Command_Concede with empty object', () => {
concede(gameId); concede(gameId);
expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith(gameId, Data.Command_Concede_ext, expect.any(Object)); expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith(gameId, Command_Concede_ext, expect.any(Object));
}); });
it('createArrow sends Command_CreateArrow', () => { it('createArrow sends Command_CreateArrow', () => {
createArrow(gameId, { startPlayerId: 1, startZone: 'hand' }); createArrow(gameId, { startPlayerId: 1, startZone: 'hand' });
expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith( expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith(
gameId, Data.Command_CreateArrow_ext, expect.objectContaining({ startPlayerId: 1, startZone: 'hand' }) gameId, Command_CreateArrow_ext, expect.objectContaining({ startPlayerId: 1, startZone: 'hand' })
); );
}); });
it('createCounter sends Command_CreateCounter', () => { it('createCounter sends Command_CreateCounter', () => {
createCounter(gameId, { counterName: 'life' }); createCounter(gameId, { counterName: 'life' });
expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith( expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith(
gameId, Data.Command_CreateCounter_ext, expect.objectContaining({ counterName: 'life' }) gameId, Command_CreateCounter_ext, expect.objectContaining({ counterName: 'life' })
); );
}); });
it('createToken sends Command_CreateToken', () => { it('createToken sends Command_CreateToken', () => {
createToken(gameId, { cardName: 'Goblin', zone: 'play' }); createToken(gameId, { cardName: 'Goblin', zone: 'play' });
expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith( expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith(
gameId, Data.Command_CreateToken_ext, expect.objectContaining({ cardName: 'Goblin', zone: 'play' }) gameId, Command_CreateToken_ext, expect.objectContaining({ cardName: 'Goblin', zone: 'play' })
); );
}); });
it('deckSelect sends Command_DeckSelect', () => { it('deckSelect sends Command_DeckSelect', () => {
deckSelect(gameId, { deckId: 5 }); deckSelect(gameId, { deckId: 5 });
expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith( expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith(
gameId, Data.Command_DeckSelect_ext, expect.objectContaining({ deckId: 5 }) gameId, Command_DeckSelect_ext, expect.objectContaining({ deckId: 5 })
); );
}); });
it('delCounter sends Command_DelCounter', () => { it('delCounter sends Command_DelCounter', () => {
delCounter(gameId, { counterId: 3 }); delCounter(gameId, { counterId: 3 });
expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith( expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith(
gameId, Data.Command_DelCounter_ext, expect.objectContaining({ counterId: 3 }) gameId, Command_DelCounter_ext, expect.objectContaining({ counterId: 3 })
); );
}); });
it('deleteArrow sends Command_DeleteArrow', () => { it('deleteArrow sends Command_DeleteArrow', () => {
deleteArrow(gameId, { arrowId: 2 }); deleteArrow(gameId, { arrowId: 2 });
expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith( expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith(
gameId, Data.Command_DeleteArrow_ext, expect.objectContaining({ arrowId: 2 }) gameId, Command_DeleteArrow_ext, expect.objectContaining({ arrowId: 2 })
); );
}); });
it('drawCards sends Command_DrawCards', () => { it('drawCards sends Command_DrawCards', () => {
drawCards(gameId, { number: 3 }); drawCards(gameId, { number: 3 });
expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith( expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith(
gameId, Data.Command_DrawCards_ext, expect.objectContaining({ number: 3 }) gameId, Command_DrawCards_ext, expect.objectContaining({ number: 3 })
); );
}); });
it('dumpZone sends Command_DumpZone', () => { it('dumpZone sends Command_DumpZone', () => {
dumpZone(gameId, { playerId: 2, zoneName: 'library' }); dumpZone(gameId, { playerId: 2, zoneName: 'library' });
expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith( expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith(
gameId, Data.Command_DumpZone_ext, expect.objectContaining({ playerId: 2, zoneName: 'library' }) gameId, Command_DumpZone_ext, expect.objectContaining({ playerId: 2, zoneName: 'library' })
); );
}); });
it('flipCard sends Command_FlipCard', () => { it('flipCard sends Command_FlipCard', () => {
flipCard(gameId, { cardId: 7, faceDown: false }); flipCard(gameId, { cardId: 7, faceDown: false });
expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith( expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith(
gameId, Data.Command_FlipCard_ext, expect.objectContaining({ cardId: 7, faceDown: false }) gameId, Command_FlipCard_ext, expect.objectContaining({ cardId: 7, faceDown: false })
); );
}); });
it('gameSay sends Command_GameSay', () => { it('gameSay sends Command_GameSay', () => {
gameSay(gameId, { message: 'hello' }); gameSay(gameId, { message: 'hello' });
expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith( expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith(
gameId, Data.Command_GameSay_ext, expect.objectContaining({ message: 'hello' }) gameId, Command_GameSay_ext, expect.objectContaining({ message: 'hello' })
); );
}); });
it('incCardCounter sends Command_IncCardCounter', () => { it('incCardCounter sends Command_IncCardCounter', () => {
incCardCounter(gameId, { cardId: 5, counterId: 1 }); incCardCounter(gameId, { cardId: 5, counterId: 1 });
expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith( expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith(
gameId, Data.Command_IncCardCounter_ext, expect.objectContaining({ cardId: 5, counterId: 1 }) gameId, Command_IncCardCounter_ext, expect.objectContaining({ cardId: 5, counterId: 1 })
); );
}); });
it('incCounter sends Command_IncCounter', () => { it('incCounter sends Command_IncCounter', () => {
incCounter(gameId, { counterId: 1, delta: 5 }); incCounter(gameId, { counterId: 1, delta: 5 });
expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith( expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith(
gameId, Data.Command_IncCounter_ext, expect.objectContaining({ counterId: 1, delta: 5 }) gameId, Command_IncCounter_ext, expect.objectContaining({ counterId: 1, delta: 5 })
); );
}); });
it('kickFromGame sends Command_KickFromGame', () => { it('kickFromGame sends Command_KickFromGame', () => {
kickFromGame(gameId, { playerId: 2 }); kickFromGame(gameId, { playerId: 2 });
expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith( expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith(
gameId, Data.Command_KickFromGame_ext, expect.objectContaining({ playerId: 2 }) gameId, Command_KickFromGame_ext, expect.objectContaining({ playerId: 2 })
); );
}); });
it('leaveGame sends Command_LeaveGame with empty object', () => { it('leaveGame sends Command_LeaveGame with empty object', () => {
leaveGame(gameId); leaveGame(gameId);
expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith(gameId, Data.Command_LeaveGame_ext, expect.any(Object)); expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith(gameId, Command_LeaveGame_ext, expect.any(Object));
}); });
it('moveCard sends Command_MoveCard', () => { it('moveCard sends Command_MoveCard', () => {
moveCard(gameId, { startZone: 'hand', targetZone: 'graveyard' }); moveCard(gameId, { startZone: 'hand', targetZone: 'graveyard' });
expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith( expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith(
gameId, Data.Command_MoveCard_ext, gameId, Command_MoveCard_ext,
expect.objectContaining({ startZone: 'hand', targetZone: 'graveyard' }) expect.objectContaining({ startZone: 'hand', targetZone: 'graveyard' })
); );
}); });
@ -175,45 +215,45 @@ describe('Game commands — delegate to WebClient.instance.protobuf.sendGameComm
it('mulligan sends Command_Mulligan', () => { it('mulligan sends Command_Mulligan', () => {
mulligan(gameId, { number: 7 }); mulligan(gameId, { number: 7 });
expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith( expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith(
gameId, Data.Command_Mulligan_ext, expect.objectContaining({ number: 7 }) gameId, Command_Mulligan_ext, expect.objectContaining({ number: 7 })
); );
}); });
it('nextTurn sends Command_NextTurn with empty object', () => { it('nextTurn sends Command_NextTurn with empty object', () => {
nextTurn(gameId); nextTurn(gameId);
expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith(gameId, Data.Command_NextTurn_ext, expect.any(Object)); expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith(gameId, Command_NextTurn_ext, expect.any(Object));
}); });
it('readyStart sends Command_ReadyStart', () => { it('readyStart sends Command_ReadyStart', () => {
readyStart(gameId, { ready: true }); readyStart(gameId, { ready: true });
expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith( expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith(
gameId, Data.Command_ReadyStart_ext, expect.objectContaining({ ready: true }) gameId, Command_ReadyStart_ext, expect.objectContaining({ ready: true })
); );
}); });
it('revealCards sends Command_RevealCards', () => { it('revealCards sends Command_RevealCards', () => {
revealCards(gameId, { zoneName: 'hand', cardId: [1, 2] }); revealCards(gameId, { zoneName: 'hand', cardId: [1, 2] });
expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith( expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith(
gameId, Data.Command_RevealCards_ext, expect.objectContaining({ zoneName: 'hand', cardId: [1, 2] }) gameId, Command_RevealCards_ext, expect.objectContaining({ zoneName: 'hand', cardId: [1, 2] })
); );
}); });
it('reverseTurn sends Command_ReverseTurn with empty object', () => { it('reverseTurn sends Command_ReverseTurn with empty object', () => {
reverseTurn(gameId); reverseTurn(gameId);
expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith(gameId, Data.Command_ReverseTurn_ext, expect.any(Object)); expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith(gameId, Command_ReverseTurn_ext, expect.any(Object));
}); });
it('setActivePhase sends Command_SetActivePhase', () => { it('setActivePhase sends Command_SetActivePhase', () => {
setActivePhase(gameId, { phase: 2 }); setActivePhase(gameId, { phase: 2 });
expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith( expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith(
gameId, Data.Command_SetActivePhase_ext, expect.objectContaining({ phase: 2 }) gameId, Command_SetActivePhase_ext, expect.objectContaining({ phase: 2 })
); );
}); });
it('setCardAttr sends Command_SetCardAttr', () => { it('setCardAttr sends Command_SetCardAttr', () => {
setCardAttr(gameId, { zone: 'play', cardId: 5, attrValue: '2' }); setCardAttr(gameId, { zone: 'play', cardId: 5, attrValue: '2' });
expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith( expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith(
gameId, Data.Command_SetCardAttr_ext, gameId, Command_SetCardAttr_ext,
expect.objectContaining({ zone: 'play', cardId: 5, attrValue: '2' }) expect.objectContaining({ zone: 'play', cardId: 5, attrValue: '2' })
); );
}); });
@ -221,63 +261,63 @@ describe('Game commands — delegate to WebClient.instance.protobuf.sendGameComm
it('setCardCounter sends Command_SetCardCounter', () => { it('setCardCounter sends Command_SetCardCounter', () => {
setCardCounter(gameId, { cardId: 5, counterId: 1 }); setCardCounter(gameId, { cardId: 5, counterId: 1 });
expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith( expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith(
gameId, Data.Command_SetCardCounter_ext, expect.objectContaining({ cardId: 5, counterId: 1 }) gameId, Command_SetCardCounter_ext, expect.objectContaining({ cardId: 5, counterId: 1 })
); );
}); });
it('setCounter sends Command_SetCounter', () => { it('setCounter sends Command_SetCounter', () => {
setCounter(gameId, { counterId: 1, value: 10 }); setCounter(gameId, { counterId: 1, value: 10 });
expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith( expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith(
gameId, Data.Command_SetCounter_ext, expect.objectContaining({ counterId: 1, value: 10 }) gameId, Command_SetCounter_ext, expect.objectContaining({ counterId: 1, value: 10 })
); );
}); });
it('setSideboardLock sends Command_SetSideboardLock', () => { it('setSideboardLock sends Command_SetSideboardLock', () => {
setSideboardLock(gameId, { locked: true }); setSideboardLock(gameId, { locked: true });
expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith( expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith(
gameId, Data.Command_SetSideboardLock_ext, expect.objectContaining({ locked: true }) gameId, Command_SetSideboardLock_ext, expect.objectContaining({ locked: true })
); );
}); });
it('setSideboardPlan sends Command_SetSideboardPlan', () => { it('setSideboardPlan sends Command_SetSideboardPlan', () => {
setSideboardPlan(gameId, { moveList: [] }); setSideboardPlan(gameId, { moveList: [] });
expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith( expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith(
gameId, Data.Command_SetSideboardPlan_ext, expect.objectContaining({ moveList: expect.any(Array) }) gameId, Command_SetSideboardPlan_ext, expect.objectContaining({ moveList: expect.any(Array) })
); );
}); });
it('shuffle sends Command_Shuffle', () => { it('shuffle sends Command_Shuffle', () => {
shuffle(gameId, { zoneName: 'hand' }); shuffle(gameId, { zoneName: 'hand' });
expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith( expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith(
gameId, Data.Command_Shuffle_ext, expect.objectContaining({ zoneName: 'hand' }) gameId, Command_Shuffle_ext, expect.objectContaining({ zoneName: 'hand' })
); );
}); });
it('undoDraw sends Command_UndoDraw with empty object', () => { it('undoDraw sends Command_UndoDraw with empty object', () => {
undoDraw(gameId); undoDraw(gameId);
expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith(gameId, Data.Command_UndoDraw_ext, expect.any(Object)); expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith(gameId, Command_UndoDraw_ext, expect.any(Object));
}); });
it('unconcede sends Command_Unconcede with empty object', () => { it('unconcede sends Command_Unconcede with empty object', () => {
unconcede(gameId); unconcede(gameId);
expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith(gameId, Data.Command_Unconcede_ext, expect.any(Object)); expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith(gameId, Command_Unconcede_ext, expect.any(Object));
}); });
it('rollDie sends Command_RollDie', () => { it('rollDie sends Command_RollDie', () => {
rollDie(gameId, { sides: 6, count: 2 }); rollDie(gameId, { sides: 6, count: 2 });
expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith( expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith(
gameId, Data.Command_RollDie_ext, expect.objectContaining({ sides: 6, count: 2 }) gameId, Command_RollDie_ext, expect.objectContaining({ sides: 6, count: 2 })
); );
}); });
it('judge sends Command_Judge with targetId and wrapped gameCommand array', () => { it('judge sends Command_Judge with targetId and wrapped gameCommand array', () => {
const targetId = 3; const targetId = 3;
const innerCmd = create(Data.GameCommandSchema); const innerCmd = create(GameCommandSchema);
setExtension(innerCmd, Data.Command_DrawCards_ext, create(Data.Command_DrawCardsSchema, { number: 2 })); setExtension(innerCmd, Command_DrawCards_ext, create(Command_DrawCardsSchema, { number: 2 }));
judge(gameId, targetId, innerCmd); judge(gameId, targetId, innerCmd);
expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith( expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith(
gameId, gameId,
Data.Command_Judge_ext, Command_Judge_ext,
expect.objectContaining({ targetId: 3, gameCommand: expect.any(Array) }) expect.objectContaining({ targetId: 3, gameCommand: expect.any(Array) })
); );
}); });

View file

@ -1,8 +1,7 @@
import { create } from '@bufbuild/protobuf'; import { create } from '@bufbuild/protobuf';
import { Command_GameSay_ext, Command_GameSaySchema, type GameSayParams } from '@app/generated';
import { WebClient } from '../../WebClient'; import { WebClient } from '../../WebClient';
import { Data } from '@app/types'; export function gameSay(gameId: number, params: GameSayParams): void {
WebClient.instance.protobuf.sendGameCommand(gameId, Command_GameSay_ext, create(Command_GameSaySchema, params));
export function gameSay(gameId: number, params: Data.GameSayParams): void {
WebClient.instance.protobuf.sendGameCommand(gameId, Data.Command_GameSay_ext, create(Data.Command_GameSaySchema, params));
} }

View file

@ -1,8 +1,7 @@
import { create } from '@bufbuild/protobuf'; import { create } from '@bufbuild/protobuf';
import { Command_IncCardCounter_ext, Command_IncCardCounterSchema, type IncCardCounterParams } from '@app/generated';
import { WebClient } from '../../WebClient'; import { WebClient } from '../../WebClient';
import { Data } from '@app/types'; export function incCardCounter(gameId: number, params: IncCardCounterParams): void {
WebClient.instance.protobuf.sendGameCommand(gameId, Command_IncCardCounter_ext, create(Command_IncCardCounterSchema, params));
export function incCardCounter(gameId: number, params: Data.IncCardCounterParams): void {
WebClient.instance.protobuf.sendGameCommand(gameId, Data.Command_IncCardCounter_ext, create(Data.Command_IncCardCounterSchema, params));
} }

View file

@ -1,8 +1,7 @@
import { create } from '@bufbuild/protobuf'; import { create } from '@bufbuild/protobuf';
import { Command_IncCounter_ext, Command_IncCounterSchema, type IncCounterParams } from '@app/generated';
import { WebClient } from '../../WebClient'; import { WebClient } from '../../WebClient';
import { Data } from '@app/types'; export function incCounter(gameId: number, params: IncCounterParams): void {
WebClient.instance.protobuf.sendGameCommand(gameId, Command_IncCounter_ext, create(Command_IncCounterSchema, params));
export function incCounter(gameId: number, params: Data.IncCounterParams): void {
WebClient.instance.protobuf.sendGameCommand(gameId, Data.Command_IncCounter_ext, create(Data.Command_IncCounterSchema, params));
} }

View file

@ -1,11 +1,10 @@
import { create } from '@bufbuild/protobuf'; import { create } from '@bufbuild/protobuf';
import { Command_Judge_ext, Command_JudgeSchema, type GameCommand } from '@app/generated';
import { WebClient } from '../../WebClient'; import { WebClient } from '../../WebClient';
import { Data } from '@app/types';
export function judge(gameId: number, targetId: number, innerGameCommand: Data.GameCommand): void { export function judge(gameId: number, targetId: number, innerGameCommand: GameCommand): void {
WebClient.instance.protobuf.sendGameCommand(gameId, Data.Command_Judge_ext, create(Data.Command_JudgeSchema, { WebClient.instance.protobuf.sendGameCommand(gameId, Command_Judge_ext, create(Command_JudgeSchema, {
targetId, targetId,
gameCommand: [innerGameCommand], gameCommand: [innerGameCommand],
})); }));
} }

View file

@ -1,8 +1,7 @@
import { create } from '@bufbuild/protobuf'; import { create } from '@bufbuild/protobuf';
import { Command_KickFromGame_ext, Command_KickFromGameSchema, type KickFromGameParams } from '@app/generated';
import { WebClient } from '../../WebClient'; import { WebClient } from '../../WebClient';
import { Data } from '@app/types'; export function kickFromGame(gameId: number, params: KickFromGameParams): void {
WebClient.instance.protobuf.sendGameCommand(gameId, Command_KickFromGame_ext, create(Command_KickFromGameSchema, params));
export function kickFromGame(gameId: number, params: Data.KickFromGameParams): void {
WebClient.instance.protobuf.sendGameCommand(gameId, Data.Command_KickFromGame_ext, create(Data.Command_KickFromGameSchema, params));
} }

View file

@ -1,7 +1,7 @@
import { create } from '@bufbuild/protobuf'; import { create } from '@bufbuild/protobuf';
import { Command_LeaveGame_ext, Command_LeaveGameSchema } from '@app/generated';
import { WebClient } from '../../WebClient'; import { WebClient } from '../../WebClient';
import { Data } from '@app/types';
export function leaveGame(gameId: number): void { export function leaveGame(gameId: number): void {
WebClient.instance.protobuf.sendGameCommand(gameId, Data.Command_LeaveGame_ext, create(Data.Command_LeaveGameSchema)); WebClient.instance.protobuf.sendGameCommand(gameId, Command_LeaveGame_ext, create(Command_LeaveGameSchema));
} }

View file

@ -1,8 +1,7 @@
import { create } from '@bufbuild/protobuf'; import { create } from '@bufbuild/protobuf';
import { Command_MoveCard_ext, Command_MoveCardSchema, type MoveCardParams } from '@app/generated';
import { WebClient } from '../../WebClient'; import { WebClient } from '../../WebClient';
import { Data } from '@app/types'; export function moveCard(gameId: number, params: MoveCardParams): void {
WebClient.instance.protobuf.sendGameCommand(gameId, Command_MoveCard_ext, create(Command_MoveCardSchema, params));
export function moveCard(gameId: number, params: Data.MoveCardParams): void {
WebClient.instance.protobuf.sendGameCommand(gameId, Data.Command_MoveCard_ext, create(Data.Command_MoveCardSchema, params));
} }

View file

@ -1,8 +1,7 @@
import { create } from '@bufbuild/protobuf'; import { create } from '@bufbuild/protobuf';
import { Command_Mulligan_ext, Command_MulliganSchema, type MulliganParams } from '@app/generated';
import { WebClient } from '../../WebClient'; import { WebClient } from '../../WebClient';
import { Data } from '@app/types'; export function mulligan(gameId: number, params: MulliganParams): void {
WebClient.instance.protobuf.sendGameCommand(gameId, Command_Mulligan_ext, create(Command_MulliganSchema, params));
export function mulligan(gameId: number, params: Data.MulliganParams): void {
WebClient.instance.protobuf.sendGameCommand(gameId, Data.Command_Mulligan_ext, create(Data.Command_MulliganSchema, params));
} }

View file

@ -1,7 +1,7 @@
import { create } from '@bufbuild/protobuf'; import { create } from '@bufbuild/protobuf';
import { Command_NextTurn_ext, Command_NextTurnSchema } from '@app/generated';
import { WebClient } from '../../WebClient'; import { WebClient } from '../../WebClient';
import { Data } from '@app/types';
export function nextTurn(gameId: number): void { export function nextTurn(gameId: number): void {
WebClient.instance.protobuf.sendGameCommand(gameId, Data.Command_NextTurn_ext, create(Data.Command_NextTurnSchema)); WebClient.instance.protobuf.sendGameCommand(gameId, Command_NextTurn_ext, create(Command_NextTurnSchema));
} }

View file

@ -1,8 +1,7 @@
import { create } from '@bufbuild/protobuf'; import { create } from '@bufbuild/protobuf';
import { Command_ReadyStart_ext, Command_ReadyStartSchema, type ReadyStartParams } from '@app/generated';
import { WebClient } from '../../WebClient'; import { WebClient } from '../../WebClient';
import { Data } from '@app/types'; export function readyStart(gameId: number, params: ReadyStartParams): void {
WebClient.instance.protobuf.sendGameCommand(gameId, Command_ReadyStart_ext, create(Command_ReadyStartSchema, params));
export function readyStart(gameId: number, params: Data.ReadyStartParams): void {
WebClient.instance.protobuf.sendGameCommand(gameId, Data.Command_ReadyStart_ext, create(Data.Command_ReadyStartSchema, params));
} }

View file

@ -1,8 +1,7 @@
import { create } from '@bufbuild/protobuf'; import { create } from '@bufbuild/protobuf';
import { Command_RevealCards_ext, Command_RevealCardsSchema, type RevealCardsParams } from '@app/generated';
import { WebClient } from '../../WebClient'; import { WebClient } from '../../WebClient';
import { Data } from '@app/types'; export function revealCards(gameId: number, params: RevealCardsParams): void {
WebClient.instance.protobuf.sendGameCommand(gameId, Command_RevealCards_ext, create(Command_RevealCardsSchema, params));
export function revealCards(gameId: number, params: Data.RevealCardsParams): void {
WebClient.instance.protobuf.sendGameCommand(gameId, Data.Command_RevealCards_ext, create(Data.Command_RevealCardsSchema, params));
} }

View file

@ -1,7 +1,7 @@
import { create } from '@bufbuild/protobuf'; import { create } from '@bufbuild/protobuf';
import { Command_ReverseTurn_ext, Command_ReverseTurnSchema } from '@app/generated';
import { WebClient } from '../../WebClient'; import { WebClient } from '../../WebClient';
import { Data } from '@app/types';
export function reverseTurn(gameId: number): void { export function reverseTurn(gameId: number): void {
WebClient.instance.protobuf.sendGameCommand(gameId, Data.Command_ReverseTurn_ext, create(Data.Command_ReverseTurnSchema)); WebClient.instance.protobuf.sendGameCommand(gameId, Command_ReverseTurn_ext, create(Command_ReverseTurnSchema));
} }

View file

@ -1,8 +1,7 @@
import { create } from '@bufbuild/protobuf'; import { create } from '@bufbuild/protobuf';
import { Command_RollDie_ext, Command_RollDieSchema, type RollDieParams } from '@app/generated';
import { WebClient } from '../../WebClient'; import { WebClient } from '../../WebClient';
import { Data } from '@app/types'; export function rollDie(gameId: number, params: RollDieParams): void {
WebClient.instance.protobuf.sendGameCommand(gameId, Command_RollDie_ext, create(Command_RollDieSchema, params));
export function rollDie(gameId: number, params: Data.RollDieParams): void {
WebClient.instance.protobuf.sendGameCommand(gameId, Data.Command_RollDie_ext, create(Data.Command_RollDieSchema, params));
} }

View file

@ -1,8 +1,7 @@
import { create } from '@bufbuild/protobuf'; import { create } from '@bufbuild/protobuf';
import { Command_SetActivePhase_ext, Command_SetActivePhaseSchema, type SetActivePhaseParams } from '@app/generated';
import { WebClient } from '../../WebClient'; import { WebClient } from '../../WebClient';
import { Data } from '@app/types'; export function setActivePhase(gameId: number, params: SetActivePhaseParams): void {
WebClient.instance.protobuf.sendGameCommand(gameId, Command_SetActivePhase_ext, create(Command_SetActivePhaseSchema, params));
export function setActivePhase(gameId: number, params: Data.SetActivePhaseParams): void {
WebClient.instance.protobuf.sendGameCommand(gameId, Data.Command_SetActivePhase_ext, create(Data.Command_SetActivePhaseSchema, params));
} }

View file

@ -1,8 +1,7 @@
import { create } from '@bufbuild/protobuf'; import { create } from '@bufbuild/protobuf';
import { Command_SetCardAttr_ext, Command_SetCardAttrSchema, type SetCardAttrParams } from '@app/generated';
import { WebClient } from '../../WebClient'; import { WebClient } from '../../WebClient';
import { Data } from '@app/types'; export function setCardAttr(gameId: number, params: SetCardAttrParams): void {
WebClient.instance.protobuf.sendGameCommand(gameId, Command_SetCardAttr_ext, create(Command_SetCardAttrSchema, params));
export function setCardAttr(gameId: number, params: Data.SetCardAttrParams): void {
WebClient.instance.protobuf.sendGameCommand(gameId, Data.Command_SetCardAttr_ext, create(Data.Command_SetCardAttrSchema, params));
} }

View file

@ -1,8 +1,7 @@
import { create } from '@bufbuild/protobuf'; import { create } from '@bufbuild/protobuf';
import { Command_SetCardCounter_ext, Command_SetCardCounterSchema, type SetCardCounterParams } from '@app/generated';
import { WebClient } from '../../WebClient'; import { WebClient } from '../../WebClient';
import { Data } from '@app/types'; export function setCardCounter(gameId: number, params: SetCardCounterParams): void {
WebClient.instance.protobuf.sendGameCommand(gameId, Command_SetCardCounter_ext, create(Command_SetCardCounterSchema, params));
export function setCardCounter(gameId: number, params: Data.SetCardCounterParams): void {
WebClient.instance.protobuf.sendGameCommand(gameId, Data.Command_SetCardCounter_ext, create(Data.Command_SetCardCounterSchema, params));
} }

View file

@ -1,8 +1,7 @@
import { create } from '@bufbuild/protobuf'; import { create } from '@bufbuild/protobuf';
import { Command_SetCounter_ext, Command_SetCounterSchema, type SetCounterParams } from '@app/generated';
import { WebClient } from '../../WebClient'; import { WebClient } from '../../WebClient';
import { Data } from '@app/types'; export function setCounter(gameId: number, params: SetCounterParams): void {
WebClient.instance.protobuf.sendGameCommand(gameId, Command_SetCounter_ext, create(Command_SetCounterSchema, params));
export function setCounter(gameId: number, params: Data.SetCounterParams): void {
WebClient.instance.protobuf.sendGameCommand(gameId, Data.Command_SetCounter_ext, create(Data.Command_SetCounterSchema, params));
} }

View file

@ -1,12 +1,11 @@
import { create } from '@bufbuild/protobuf'; import { create } from '@bufbuild/protobuf';
import { Command_SetSideboardLock_ext, Command_SetSideboardLockSchema, type SetSideboardLockParams } from '@app/generated';
import { WebClient } from '../../WebClient'; import { WebClient } from '../../WebClient';
import { Data } from '@app/types'; export function setSideboardLock(gameId: number, params: SetSideboardLockParams): void {
export function setSideboardLock(gameId: number, params: Data.SetSideboardLockParams): void {
WebClient.instance.protobuf.sendGameCommand( WebClient.instance.protobuf.sendGameCommand(
gameId, gameId,
Data.Command_SetSideboardLock_ext, Command_SetSideboardLock_ext,
create(Data.Command_SetSideboardLockSchema, params) create(Command_SetSideboardLockSchema, params)
); );
} }

View file

@ -1,12 +1,11 @@
import { create } from '@bufbuild/protobuf'; import { create } from '@bufbuild/protobuf';
import { Command_SetSideboardPlan_ext, Command_SetSideboardPlanSchema, type SetSideboardPlanParams } from '@app/generated';
import { WebClient } from '../../WebClient'; import { WebClient } from '../../WebClient';
import { Data } from '@app/types'; export function setSideboardPlan(gameId: number, params: SetSideboardPlanParams): void {
export function setSideboardPlan(gameId: number, params: Data.SetSideboardPlanParams): void {
WebClient.instance.protobuf.sendGameCommand( WebClient.instance.protobuf.sendGameCommand(
gameId, gameId,
Data.Command_SetSideboardPlan_ext, Command_SetSideboardPlan_ext,
create(Data.Command_SetSideboardPlanSchema, params) create(Command_SetSideboardPlanSchema, params)
); );
} }

View file

@ -1,8 +1,7 @@
import { create } from '@bufbuild/protobuf'; import { create } from '@bufbuild/protobuf';
import { Command_Shuffle_ext, Command_ShuffleSchema, type ShuffleParams } from '@app/generated';
import { WebClient } from '../../WebClient'; import { WebClient } from '../../WebClient';
import { Data } from '@app/types'; export function shuffle(gameId: number, params: ShuffleParams): void {
WebClient.instance.protobuf.sendGameCommand(gameId, Command_Shuffle_ext, create(Command_ShuffleSchema, params));
export function shuffle(gameId: number, params: Data.ShuffleParams): void {
WebClient.instance.protobuf.sendGameCommand(gameId, Data.Command_Shuffle_ext, create(Data.Command_ShuffleSchema, params));
} }

View file

@ -1,7 +1,7 @@
import { create } from '@bufbuild/protobuf'; import { create } from '@bufbuild/protobuf';
import { Command_Unconcede_ext, Command_UnconcedeSchema } from '@app/generated';
import { WebClient } from '../../WebClient'; import { WebClient } from '../../WebClient';
import { Data } from '@app/types';
export function unconcede(gameId: number): void { export function unconcede(gameId: number): void {
WebClient.instance.protobuf.sendGameCommand(gameId, Data.Command_Unconcede_ext, create(Data.Command_UnconcedeSchema)); WebClient.instance.protobuf.sendGameCommand(gameId, Command_Unconcede_ext, create(Command_UnconcedeSchema));
} }

View file

@ -1,7 +1,7 @@
import { create } from '@bufbuild/protobuf'; import { create } from '@bufbuild/protobuf';
import { Command_UndoDraw_ext, Command_UndoDrawSchema } from '@app/generated';
import { WebClient } from '../../WebClient'; import { WebClient } from '../../WebClient';
import { Data } from '@app/types';
export function undoDraw(gameId: number): void { export function undoDraw(gameId: number): void {
WebClient.instance.protobuf.sendGameCommand(gameId, Data.Command_UndoDraw_ext, create(Data.Command_UndoDrawSchema)); WebClient.instance.protobuf.sendGameCommand(gameId, Command_UndoDraw_ext, create(Command_UndoDrawSchema));
} }

View file

@ -1,11 +1,11 @@
import { create } from '@bufbuild/protobuf'; import { create } from '@bufbuild/protobuf';
import { WebClient } from '../../WebClient'; import { WebClient } from '../../WebClient';
import { Data } from '@app/types'; import { Command_BanFromServer_ext, Command_BanFromServerSchema } from '@app/generated';
export function banFromServer(minutes: number, userName?: string, address?: string, reason?: string, export function banFromServer(minutes: number, userName?: string, address?: string, reason?: string,
visibleReason?: string, clientid?: string, removeMessages?: number): void { visibleReason?: string, clientid?: string, removeMessages?: number): void {
WebClient.instance.protobuf.sendModeratorCommand(Data.Command_BanFromServer_ext, create(Data.Command_BanFromServerSchema, { WebClient.instance.protobuf.sendModeratorCommand(Command_BanFromServer_ext, create(Command_BanFromServerSchema, {
minutes, userName, address, reason, visibleReason, clientid, removeMessages minutes, userName, address, reason, visibleReason, clientid, removeMessages
}), { }), {
onSuccess: () => { onSuccess: () => {

View file

@ -1,11 +1,11 @@
import { create } from '@bufbuild/protobuf'; import { create } from '@bufbuild/protobuf';
import { WebClient } from '../../WebClient'; import { WebClient } from '../../WebClient';
import { Data } from '@app/types'; import { Command_ForceActivateUser_ext, Command_ForceActivateUserSchema } from '@app/generated';
export function forceActivateUser(usernameToActivate: string, moderatorName: string): void { export function forceActivateUser(usernameToActivate: string, moderatorName: string): void {
const cmd = create(Data.Command_ForceActivateUserSchema, { usernameToActivate, moderatorName }); const cmd = create(Command_ForceActivateUserSchema, { usernameToActivate, moderatorName });
WebClient.instance.protobuf.sendModeratorCommand(Data.Command_ForceActivateUser_ext, cmd, { WebClient.instance.protobuf.sendModeratorCommand(Command_ForceActivateUser_ext, cmd, {
onSuccess: () => { onSuccess: () => {
WebClient.instance.response.moderator.forceActivateUser(usernameToActivate, moderatorName); WebClient.instance.response.moderator.forceActivateUser(usernameToActivate, moderatorName);
}, },

View file

@ -1,11 +1,11 @@
import { create } from '@bufbuild/protobuf'; import { create } from '@bufbuild/protobuf';
import { WebClient } from '../../WebClient'; import { WebClient } from '../../WebClient';
import { Data } from '@app/types'; import { Command_GetAdminNotes_ext, Command_GetAdminNotesSchema, Response_GetAdminNotes_ext } from '@app/generated';
export function getAdminNotes(userName: string): void { export function getAdminNotes(userName: string): void {
WebClient.instance.protobuf.sendModeratorCommand(Data.Command_GetAdminNotes_ext, create(Data.Command_GetAdminNotesSchema, { userName }), { WebClient.instance.protobuf.sendModeratorCommand(Command_GetAdminNotes_ext, create(Command_GetAdminNotesSchema, { userName }), {
responseExt: Data.Response_GetAdminNotes_ext, responseExt: Response_GetAdminNotes_ext,
onSuccess: (response) => { onSuccess: (response) => {
WebClient.instance.response.moderator.getAdminNotes(userName, response.notes); WebClient.instance.response.moderator.getAdminNotes(userName, response.notes);
}, },

View file

@ -1,11 +1,11 @@
import { create } from '@bufbuild/protobuf'; import { create } from '@bufbuild/protobuf';
import { WebClient } from '../../WebClient'; import { WebClient } from '../../WebClient';
import { Data } from '@app/types'; import { Command_GetBanHistory_ext, Command_GetBanHistorySchema, Response_BanHistory_ext } from '@app/generated';
export function getBanHistory(userName: string): void { export function getBanHistory(userName: string): void {
WebClient.instance.protobuf.sendModeratorCommand(Data.Command_GetBanHistory_ext, create(Data.Command_GetBanHistorySchema, { userName }), { WebClient.instance.protobuf.sendModeratorCommand(Command_GetBanHistory_ext, create(Command_GetBanHistorySchema, { userName }), {
responseExt: Data.Response_BanHistory_ext, responseExt: Response_BanHistory_ext,
onSuccess: (response) => { onSuccess: (response) => {
WebClient.instance.response.moderator.banHistory(userName, response.banList); WebClient.instance.response.moderator.banHistory(userName, response.banList);
}, },

View file

@ -1,14 +1,14 @@
import { create } from '@bufbuild/protobuf'; import { create } from '@bufbuild/protobuf';
import { WebClient } from '../../WebClient'; import { WebClient } from '../../WebClient';
import { Data } from '@app/types'; import { Command_GetWarnHistory_ext, Command_GetWarnHistorySchema, Response_WarnHistory_ext } from '@app/generated';
export function getWarnHistory(userName: string): void { export function getWarnHistory(userName: string): void {
WebClient.instance.protobuf.sendModeratorCommand( WebClient.instance.protobuf.sendModeratorCommand(
Data.Command_GetWarnHistory_ext, Command_GetWarnHistory_ext,
create(Data.Command_GetWarnHistorySchema, { userName }), create(Command_GetWarnHistorySchema, { userName }),
{ {
responseExt: Data.Response_WarnHistory_ext, responseExt: Response_WarnHistory_ext,
onSuccess: (response) => { onSuccess: (response) => {
WebClient.instance.response.moderator.warnHistory(userName, response.warnList); WebClient.instance.response.moderator.warnHistory(userName, response.warnList);
}, },

View file

@ -1,14 +1,14 @@
import { create } from '@bufbuild/protobuf'; import { create } from '@bufbuild/protobuf';
import { WebClient } from '../../WebClient'; import { WebClient } from '../../WebClient';
import { Data } from '@app/types'; import { Command_GetWarnList_ext, Command_GetWarnListSchema, Response_WarnList_ext } from '@app/generated';
export function getWarnList(modName: string, userName: string, userClientid: string): void { export function getWarnList(modName: string, userName: string, userClientid: string): void {
WebClient.instance.protobuf.sendModeratorCommand( WebClient.instance.protobuf.sendModeratorCommand(
Data.Command_GetWarnList_ext, Command_GetWarnList_ext,
create(Data.Command_GetWarnListSchema, { modName, userName, userClientid }), create(Command_GetWarnListSchema, { modName, userName, userClientid }),
{ {
responseExt: Data.Response_WarnList_ext, responseExt: Response_WarnList_ext,
onSuccess: (response) => { onSuccess: (response) => {
WebClient.instance.response.moderator.warnListOptions([response]); WebClient.instance.response.moderator.warnListOptions([response]);
}, },

View file

@ -1,12 +1,12 @@
import { create } from '@bufbuild/protobuf'; import { create } from '@bufbuild/protobuf';
import { WebClient } from '../../WebClient'; import { WebClient } from '../../WebClient';
import { Data } from '@app/types'; import { Command_GrantReplayAccess_ext, Command_GrantReplayAccessSchema } from '@app/generated';
export function grantReplayAccess(replayId: number, moderatorName: string): void { export function grantReplayAccess(replayId: number, moderatorName: string): void {
WebClient.instance.protobuf.sendModeratorCommand( WebClient.instance.protobuf.sendModeratorCommand(
Data.Command_GrantReplayAccess_ext, Command_GrantReplayAccess_ext,
create(Data.Command_GrantReplayAccessSchema, { replayId, moderatorName }), create(Command_GrantReplayAccessSchema, { replayId, moderatorName }),
{ {
onSuccess: () => { onSuccess: () => {
WebClient.instance.response.moderator.grantReplayAccess(replayId, moderatorName); WebClient.instance.response.moderator.grantReplayAccess(replayId, moderatorName);

View file

@ -21,8 +21,26 @@ vi.mock('../../WebClient', () => ({
})); }));
import { makeCallbackHelpers } from '../../__mocks__/callbackHelpers'; import { makeCallbackHelpers } from '../../__mocks__/callbackHelpers';
import { useWebClientCleanup } from '../../__mocks__/helpers';
import { WebClient } from '../../WebClient'; import { WebClient } from '../../WebClient';
import { Data } from '@app/types'; import {
Command_BanFromServer_ext,
Command_ForceActivateUser_ext,
Command_GetAdminNotes_ext,
Command_GetBanHistory_ext,
Command_GetWarnHistory_ext,
Command_GetWarnList_ext,
Command_GrantReplayAccess_ext,
Command_UpdateAdminNotes_ext,
Command_ViewLogHistory_ext,
Command_ViewLogHistorySchema,
Command_WarnUser_ext,
Response_BanHistory_ext,
Response_GetAdminNotes_ext,
Response_ViewLogHistory_ext,
Response_WarnHistory_ext,
Response_WarnList_ext,
} from '@app/generated';
import { banFromServer } from './banFromServer'; import { banFromServer } from './banFromServer';
import { forceActivateUser } from './forceActivateUser'; import { forceActivateUser } from './forceActivateUser';
@ -37,6 +55,8 @@ import { warnUser } from './warnUser';
import { create } from '@bufbuild/protobuf'; import { create } from '@bufbuild/protobuf';
import { Mock } from 'vitest'; import { Mock } from 'vitest';
useWebClientCleanup();
const { invokeOnSuccess } = makeCallbackHelpers( const { invokeOnSuccess } = makeCallbackHelpers(
WebClient.instance.protobuf.sendModeratorCommand as Mock, WebClient.instance.protobuf.sendModeratorCommand as Mock,
2 2
@ -50,7 +70,7 @@ describe('banFromServer', () => {
it('calls sendModeratorCommand with Command_BanFromServer', () => { it('calls sendModeratorCommand with Command_BanFromServer', () => {
banFromServer(30, 'alice', '1.2.3.4', 'reason', 'visible', 'cid', 1); banFromServer(30, 'alice', '1.2.3.4', 'reason', 'visible', 'cid', 1);
expect(WebClient.instance.protobuf.sendModeratorCommand).toHaveBeenCalledWith( expect(WebClient.instance.protobuf.sendModeratorCommand).toHaveBeenCalledWith(
Data.Command_BanFromServer_ext, Command_BanFromServer_ext,
expect.objectContaining({ minutes: 30, userName: 'alice' }), expect.objectContaining({ minutes: 30, userName: 'alice' }),
expect.any(Object) expect.any(Object)
); );
@ -71,7 +91,7 @@ describe('forceActivateUser', () => {
it('calls sendModeratorCommand with Command_ForceActivateUser', () => { it('calls sendModeratorCommand with Command_ForceActivateUser', () => {
forceActivateUser('alice', 'mod1'); forceActivateUser('alice', 'mod1');
expect(WebClient.instance.protobuf.sendModeratorCommand).toHaveBeenCalledWith( expect(WebClient.instance.protobuf.sendModeratorCommand).toHaveBeenCalledWith(
Data.Command_ForceActivateUser_ext, expect.any(Object), expect.any(Object) Command_ForceActivateUser_ext, expect.any(Object), expect.any(Object)
); );
}); });
@ -90,9 +110,9 @@ describe('getAdminNotes', () => {
it('calls sendModeratorCommand with Command_GetAdminNotes', () => { it('calls sendModeratorCommand with Command_GetAdminNotes', () => {
getAdminNotes('alice'); getAdminNotes('alice');
expect(WebClient.instance.protobuf.sendModeratorCommand).toHaveBeenCalledWith( expect(WebClient.instance.protobuf.sendModeratorCommand).toHaveBeenCalledWith(
Data.Command_GetAdminNotes_ext, Command_GetAdminNotes_ext,
expect.any(Object), expect.any(Object),
expect.objectContaining({ responseExt: Data.Response_GetAdminNotes_ext }) expect.objectContaining({ responseExt: Response_GetAdminNotes_ext })
); );
}); });
@ -112,9 +132,9 @@ describe('getBanHistory', () => {
it('calls sendModeratorCommand with Command_GetBanHistory', () => { it('calls sendModeratorCommand with Command_GetBanHistory', () => {
getBanHistory('alice'); getBanHistory('alice');
expect(WebClient.instance.protobuf.sendModeratorCommand).toHaveBeenCalledWith( expect(WebClient.instance.protobuf.sendModeratorCommand).toHaveBeenCalledWith(
Data.Command_GetBanHistory_ext, Command_GetBanHistory_ext,
expect.any(Object), expect.any(Object),
expect.objectContaining({ responseExt: Data.Response_BanHistory_ext }) expect.objectContaining({ responseExt: Response_BanHistory_ext })
); );
}); });
@ -134,9 +154,9 @@ describe('getWarnHistory', () => {
it('calls sendModeratorCommand with Command_GetWarnHistory', () => { it('calls sendModeratorCommand with Command_GetWarnHistory', () => {
getWarnHistory('alice'); getWarnHistory('alice');
expect(WebClient.instance.protobuf.sendModeratorCommand).toHaveBeenCalledWith( expect(WebClient.instance.protobuf.sendModeratorCommand).toHaveBeenCalledWith(
Data.Command_GetWarnHistory_ext, Command_GetWarnHistory_ext,
expect.any(Object), expect.any(Object),
expect.objectContaining({ responseExt: Data.Response_WarnHistory_ext }) expect.objectContaining({ responseExt: Response_WarnHistory_ext })
); );
}); });
@ -156,9 +176,9 @@ describe('getWarnList', () => {
it('calls sendModeratorCommand with Command_GetWarnList', () => { it('calls sendModeratorCommand with Command_GetWarnList', () => {
getWarnList('mod1', 'alice', 'US'); getWarnList('mod1', 'alice', 'US');
expect(WebClient.instance.protobuf.sendModeratorCommand).toHaveBeenCalledWith( expect(WebClient.instance.protobuf.sendModeratorCommand).toHaveBeenCalledWith(
Data.Command_GetWarnList_ext, Command_GetWarnList_ext,
expect.any(Object), expect.any(Object),
expect.objectContaining({ responseExt: Data.Response_WarnList_ext }) expect.objectContaining({ responseExt: Response_WarnList_ext })
); );
}); });
@ -178,7 +198,7 @@ describe('grantReplayAccess', () => {
it('calls sendModeratorCommand with Command_GrantReplayAccess', () => { it('calls sendModeratorCommand with Command_GrantReplayAccess', () => {
grantReplayAccess(10, 'mod1'); grantReplayAccess(10, 'mod1');
expect(WebClient.instance.protobuf.sendModeratorCommand).toHaveBeenCalledWith( expect(WebClient.instance.protobuf.sendModeratorCommand).toHaveBeenCalledWith(
Data.Command_GrantReplayAccess_ext, expect.any(Object), expect.any(Object) Command_GrantReplayAccess_ext, expect.any(Object), expect.any(Object)
); );
}); });
@ -197,7 +217,7 @@ describe('updateAdminNotes', () => {
it('calls sendModeratorCommand with Command_UpdateAdminNotes', () => { it('calls sendModeratorCommand with Command_UpdateAdminNotes', () => {
updateAdminNotes('alice', 'new notes'); updateAdminNotes('alice', 'new notes');
expect(WebClient.instance.protobuf.sendModeratorCommand).toHaveBeenCalledWith( expect(WebClient.instance.protobuf.sendModeratorCommand).toHaveBeenCalledWith(
Data.Command_UpdateAdminNotes_ext, expect.any(Object), expect.any(Object) Command_UpdateAdminNotes_ext, expect.any(Object), expect.any(Object)
); );
}); });
@ -214,17 +234,17 @@ describe('updateAdminNotes', () => {
describe('viewLogHistory', () => { describe('viewLogHistory', () => {
it('calls sendModeratorCommand with Command_ViewLogHistory', () => { it('calls sendModeratorCommand with Command_ViewLogHistory', () => {
const filters = create(Data.Command_ViewLogHistorySchema, { dateRange: 7 }); const filters = create(Command_ViewLogHistorySchema, { dateRange: 7 });
viewLogHistory(filters); viewLogHistory(filters);
expect(WebClient.instance.protobuf.sendModeratorCommand).toHaveBeenCalledWith( expect(WebClient.instance.protobuf.sendModeratorCommand).toHaveBeenCalledWith(
Data.Command_ViewLogHistory_ext, Command_ViewLogHistory_ext,
expect.any(Object), expect.any(Object),
expect.objectContaining({ responseExt: Data.Response_ViewLogHistory_ext }) expect.objectContaining({ responseExt: Response_ViewLogHistory_ext })
); );
}); });
it('onSuccess calls response.moderator.viewLogs with logMessage', () => { it('onSuccess calls response.moderator.viewLogs with logMessage', () => {
const filters = create(Data.Command_ViewLogHistorySchema, { dateRange: 7 }); const filters = create(Command_ViewLogHistorySchema, { dateRange: 7 });
viewLogHistory(filters); viewLogHistory(filters);
const resp = { logMessage: ['log1'] }; const resp = { logMessage: ['log1'] };
invokeOnSuccess(resp, { responseCode: 0 }); invokeOnSuccess(resp, { responseCode: 0 });
@ -240,7 +260,7 @@ describe('warnUser', () => {
it('calls sendModeratorCommand with Command_WarnUser', () => { it('calls sendModeratorCommand with Command_WarnUser', () => {
warnUser('alice', 'bad behavior', 'cid'); warnUser('alice', 'bad behavior', 'cid');
expect(WebClient.instance.protobuf.sendModeratorCommand).toHaveBeenCalledWith( expect(WebClient.instance.protobuf.sendModeratorCommand).toHaveBeenCalledWith(
Data.Command_WarnUser_ext, expect.any(Object), expect.any(Object) Command_WarnUser_ext, expect.any(Object), expect.any(Object)
); );
}); });

View file

@ -1,12 +1,12 @@
import { create } from '@bufbuild/protobuf'; import { create } from '@bufbuild/protobuf';
import { WebClient } from '../../WebClient'; import { WebClient } from '../../WebClient';
import { Data } from '@app/types'; import { Command_UpdateAdminNotes_ext, Command_UpdateAdminNotesSchema } from '@app/generated';
export function updateAdminNotes(userName: string, notes: string): void { export function updateAdminNotes(userName: string, notes: string): void {
WebClient.instance.protobuf.sendModeratorCommand( WebClient.instance.protobuf.sendModeratorCommand(
Data.Command_UpdateAdminNotes_ext, Command_UpdateAdminNotes_ext,
create(Data.Command_UpdateAdminNotesSchema, { userName, notes }), create(Command_UpdateAdminNotesSchema, { userName, notes }),
{ {
onSuccess: () => { onSuccess: () => {
WebClient.instance.response.moderator.updateAdminNotes(userName, notes); WebClient.instance.response.moderator.updateAdminNotes(userName, notes);

View file

@ -1,11 +1,12 @@
import { create } from '@bufbuild/protobuf'; import { create } from '@bufbuild/protobuf';
import { WebClient } from '../../WebClient'; import { WebClient } from '../../WebClient';
import { Data } from '@app/types'; import { Command_ViewLogHistory_ext, Command_ViewLogHistorySchema, Response_ViewLogHistory_ext } from '@app/generated';
import type { ViewLogHistoryParams } from '@app/generated';
export function viewLogHistory(filters: Data.ViewLogHistoryParams): void { export function viewLogHistory(filters: ViewLogHistoryParams): void {
WebClient.instance.protobuf.sendModeratorCommand(Data.Command_ViewLogHistory_ext, create(Data.Command_ViewLogHistorySchema, filters), { WebClient.instance.protobuf.sendModeratorCommand(Command_ViewLogHistory_ext, create(Command_ViewLogHistorySchema, filters), {
responseExt: Data.Response_ViewLogHistory_ext, responseExt: Response_ViewLogHistory_ext,
onSuccess: (response) => { onSuccess: (response) => {
WebClient.instance.response.moderator.viewLogs(response.logMessage); WebClient.instance.response.moderator.viewLogs(response.logMessage);
}, },

View file

@ -1,11 +1,11 @@
import { create } from '@bufbuild/protobuf'; import { create } from '@bufbuild/protobuf';
import { WebClient } from '../../WebClient'; import { WebClient } from '../../WebClient';
import { Data } from '@app/types'; import { Command_WarnUser_ext, Command_WarnUserSchema } from '@app/generated';
export function warnUser(userName: string, reason: string, clientid?: string, removeMessages?: number): void { export function warnUser(userName: string, reason: string, clientid?: string, removeMessages?: number): void {
const cmd = create(Data.Command_WarnUserSchema, { userName, reason, clientid, removeMessages }); const cmd = create(Command_WarnUserSchema, { userName, reason, clientid, removeMessages });
WebClient.instance.protobuf.sendModeratorCommand(Data.Command_WarnUser_ext, cmd, { WebClient.instance.protobuf.sendModeratorCommand(Command_WarnUser_ext, cmd, {
onSuccess: () => { onSuccess: () => {
WebClient.instance.response.moderator.warnUser(userName); WebClient.instance.response.moderator.warnUser(userName);
}, },

View file

@ -1,10 +1,11 @@
import { create } from '@bufbuild/protobuf'; import { create } from '@bufbuild/protobuf';
import { WebClient } from '../../WebClient'; import { WebClient } from '../../WebClient';
import { Data } from '@app/types'; import { Command_CreateGame_ext, Command_CreateGameSchema } from '@app/generated';
import type { CreateGameParams } from '@app/generated';
export function createGame(roomId: number, gameConfig: Data.CreateGameParams): void { export function createGame(roomId: number, gameConfig: CreateGameParams): void {
WebClient.instance.protobuf.sendRoomCommand(roomId, Data.Command_CreateGame_ext, create(Data.Command_CreateGameSchema, gameConfig), { WebClient.instance.protobuf.sendRoomCommand(roomId, Command_CreateGame_ext, create(Command_CreateGameSchema, gameConfig), {
onSuccess: () => { onSuccess: () => {
WebClient.instance.response.room.gameCreated(roomId); WebClient.instance.response.room.gameCreated(roomId);
}, },

View file

@ -1,10 +1,11 @@
import { create } from '@bufbuild/protobuf'; import { create } from '@bufbuild/protobuf';
import { WebClient } from '../../WebClient'; import { WebClient } from '../../WebClient';
import { Data } from '@app/types'; import { Command_JoinGame_ext, Command_JoinGameSchema } from '@app/generated';
import type { JoinGameParams } from '@app/generated';
export function joinGame(roomId: number, joinGameParams: Data.JoinGameParams): void { export function joinGame(roomId: number, joinGameParams: JoinGameParams): void {
WebClient.instance.protobuf.sendRoomCommand(roomId, Data.Command_JoinGame_ext, create(Data.Command_JoinGameSchema, joinGameParams), { WebClient.instance.protobuf.sendRoomCommand(roomId, Command_JoinGame_ext, create(Command_JoinGameSchema, joinGameParams), {
onSuccess: () => { onSuccess: () => {
WebClient.instance.response.room.joinedGame(roomId, joinGameParams.gameId); WebClient.instance.response.room.joinedGame(roomId, joinGameParams.gameId);
}, },

View file

@ -1,10 +1,10 @@
import { create } from '@bufbuild/protobuf'; import { create } from '@bufbuild/protobuf';
import { WebClient } from '../../WebClient'; import { WebClient } from '../../WebClient';
import { Data } from '@app/types'; import { Command_LeaveRoom_ext, Command_LeaveRoomSchema } from '@app/generated';
export function leaveRoom(roomId: number): void { export function leaveRoom(roomId: number): void {
WebClient.instance.protobuf.sendRoomCommand(roomId, Data.Command_LeaveRoom_ext, create(Data.Command_LeaveRoomSchema), { WebClient.instance.protobuf.sendRoomCommand(roomId, Command_LeaveRoom_ext, create(Command_LeaveRoomSchema), {
onSuccess: () => { onSuccess: () => {
WebClient.instance.response.room.leaveRoom(roomId); WebClient.instance.response.room.leaveRoom(roomId);
}, },

View file

@ -14,8 +14,16 @@ vi.mock('../../WebClient', () => ({
})); }));
import { makeCallbackHelpers } from '../../__mocks__/callbackHelpers'; import { makeCallbackHelpers } from '../../__mocks__/callbackHelpers';
import { useWebClientCleanup } from '../../__mocks__/helpers';
import { WebClient } from '../../WebClient'; import { WebClient } from '../../WebClient';
import { Data } from '@app/types'; import {
Command_CreateGame_ext,
Command_CreateGameSchema,
Command_JoinGame_ext,
Command_JoinGameSchema,
Command_LeaveRoom_ext,
Command_RoomSay_ext,
} from '@app/generated';
import { createGame } from './createGame'; import { createGame } from './createGame';
import { joinGame } from './joinGame'; import { joinGame } from './joinGame';
@ -24,6 +32,8 @@ import { roomSay } from './roomSay';
import { create } from '@bufbuild/protobuf'; import { create } from '@bufbuild/protobuf';
import { Mock } from 'vitest'; import { Mock } from 'vitest';
useWebClientCleanup();
const { invokeOnSuccess } = makeCallbackHelpers( const { invokeOnSuccess } = makeCallbackHelpers(
WebClient.instance.protobuf.sendRoomCommand as Mock, WebClient.instance.protobuf.sendRoomCommand as Mock,
// sendRoomCommand(roomId, ext, value, options) — options at index 3 // sendRoomCommand(roomId, ext, value, options) — options at index 3
@ -36,14 +46,14 @@ const { invokeOnSuccess } = makeCallbackHelpers(
describe('createGame', () => { describe('createGame', () => {
it('calls sendRoomCommand with Command_CreateGame', () => { it('calls sendRoomCommand with Command_CreateGame', () => {
createGame(5, create(Data.Command_CreateGameSchema, { maxPlayers: 4 })); createGame(5, create(Command_CreateGameSchema, { maxPlayers: 4 }));
expect(WebClient.instance.protobuf.sendRoomCommand).toHaveBeenCalledWith( expect(WebClient.instance.protobuf.sendRoomCommand).toHaveBeenCalledWith(
5, Data.Command_CreateGame_ext, expect.objectContaining({ maxPlayers: 4 }), expect.any(Object) 5, Command_CreateGame_ext, expect.objectContaining({ maxPlayers: 4 }), expect.any(Object)
); );
}); });
it('onSuccess calls response.room.gameCreated with roomId', () => { it('onSuccess calls response.room.gameCreated with roomId', () => {
createGame(5, create(Data.Command_CreateGameSchema, {})); createGame(5, create(Command_CreateGameSchema, {}));
invokeOnSuccess(); invokeOnSuccess();
expect(WebClient.instance.response.room.gameCreated).toHaveBeenCalledWith(5); expect(WebClient.instance.response.room.gameCreated).toHaveBeenCalledWith(5);
}); });
@ -55,14 +65,14 @@ describe('createGame', () => {
describe('joinGame', () => { describe('joinGame', () => {
it('calls sendRoomCommand with Command_JoinGame', () => { it('calls sendRoomCommand with Command_JoinGame', () => {
joinGame(7, create(Data.Command_JoinGameSchema, { gameId: 42, password: '' })); joinGame(7, create(Command_JoinGameSchema, { gameId: 42, password: '' }));
expect(WebClient.instance.protobuf.sendRoomCommand).toHaveBeenCalledWith( expect(WebClient.instance.protobuf.sendRoomCommand).toHaveBeenCalledWith(
7, Data.Command_JoinGame_ext, expect.objectContaining({ gameId: 42, password: '' }), expect.any(Object) 7, Command_JoinGame_ext, expect.objectContaining({ gameId: 42, password: '' }), expect.any(Object)
); );
}); });
it('onSuccess calls response.room.joinedGame with roomId and gameId', () => { it('onSuccess calls response.room.joinedGame with roomId and gameId', () => {
joinGame(7, create(Data.Command_JoinGameSchema, { gameId: 42 })); joinGame(7, create(Command_JoinGameSchema, { gameId: 42 }));
invokeOnSuccess(); invokeOnSuccess();
expect(WebClient.instance.response.room.joinedGame).toHaveBeenCalledWith(7, 42); expect(WebClient.instance.response.room.joinedGame).toHaveBeenCalledWith(7, 42);
}); });
@ -76,7 +86,7 @@ describe('leaveRoom', () => {
it('calls sendRoomCommand with Command_LeaveRoom', () => { it('calls sendRoomCommand with Command_LeaveRoom', () => {
leaveRoom(3); leaveRoom(3);
expect(WebClient.instance.protobuf.sendRoomCommand).toHaveBeenCalledWith( expect(WebClient.instance.protobuf.sendRoomCommand).toHaveBeenCalledWith(
3, Data.Command_LeaveRoom_ext, expect.any(Object), expect.any(Object) 3, Command_LeaveRoom_ext, expect.any(Object), expect.any(Object)
); );
}); });
@ -96,7 +106,7 @@ describe('roomSay', () => {
roomSay(2, ' hello '); roomSay(2, ' hello ');
expect(WebClient.instance.protobuf.sendRoomCommand).toHaveBeenCalledWith( expect(WebClient.instance.protobuf.sendRoomCommand).toHaveBeenCalledWith(
2, 2,
Data.Command_RoomSay_ext, Command_RoomSay_ext,
expect.objectContaining({ message: 'hello' }) expect.objectContaining({ message: 'hello' })
); );
}); });

View file

@ -1,6 +1,6 @@
import { create } from '@bufbuild/protobuf'; import { create } from '@bufbuild/protobuf';
import { WebClient } from '../../WebClient'; import { WebClient } from '../../WebClient';
import { Data } from '@app/types'; import { Command_RoomSay_ext, Command_RoomSaySchema } from '@app/generated';
export function roomSay(roomId: number, message: string): void { export function roomSay(roomId: number, message: string): void {
const trimmed = message.trim(); const trimmed = message.trim();
@ -9,5 +9,5 @@ export function roomSay(roomId: number, message: string): void {
return; return;
} }
WebClient.instance.protobuf.sendRoomCommand(roomId, Data.Command_RoomSay_ext, create(Data.Command_RoomSaySchema, { message: trimmed })); WebClient.instance.protobuf.sendRoomCommand(roomId, Command_RoomSay_ext, create(Command_RoomSaySchema, { message: trimmed }));
} }

View file

@ -1,11 +1,11 @@
import { create } from '@bufbuild/protobuf'; import { create } from '@bufbuild/protobuf';
import { WebClient } from '../../WebClient'; import { WebClient } from '../../WebClient';
import { Data } from '@app/types'; import { Command_AccountEdit_ext, Command_AccountEditSchema } from '@app/generated';
export function accountEdit(passwordCheck: string, realName?: string, email?: string, country?: string): void { export function accountEdit(passwordCheck: string, realName?: string, email?: string, country?: string): void {
const cmd = create(Data.Command_AccountEditSchema, { passwordCheck, realName, email, country }); const cmd = create(Command_AccountEditSchema, { passwordCheck, realName, email, country });
WebClient.instance.protobuf.sendSessionCommand(Data.Command_AccountEdit_ext, cmd, { WebClient.instance.protobuf.sendSessionCommand(Command_AccountEdit_ext, cmd, {
onSuccess: () => { onSuccess: () => {
WebClient.instance.response.session.accountEditChanged(realName, email, country); WebClient.instance.response.session.accountEditChanged(realName, email, country);
}, },

View file

@ -1,10 +1,10 @@
import { create } from '@bufbuild/protobuf'; import { create } from '@bufbuild/protobuf';
import { WebClient } from '../../WebClient'; import { WebClient } from '../../WebClient';
import { Data } from '@app/types'; import { Command_AccountImage_ext, Command_AccountImageSchema } from '@app/generated';
export function accountImage(image: Uint8Array): void { export function accountImage(image: Uint8Array): void {
WebClient.instance.protobuf.sendSessionCommand(Data.Command_AccountImage_ext, create(Data.Command_AccountImageSchema, { image }), { WebClient.instance.protobuf.sendSessionCommand(Command_AccountImage_ext, create(Command_AccountImageSchema, { image }), {
onSuccess: () => { onSuccess: () => {
WebClient.instance.response.session.accountImageChanged(image); WebClient.instance.response.session.accountImageChanged(image);
}, },

View file

@ -1,11 +1,11 @@
import { create } from '@bufbuild/protobuf'; import { create } from '@bufbuild/protobuf';
import { WebClient } from '../../WebClient'; import { WebClient } from '../../WebClient';
import { Data } from '@app/types'; import { Command_AccountPassword_ext, Command_AccountPasswordSchema } from '@app/generated';
export function accountPassword(oldPassword: string, newPassword: string, hashedNewPassword: string): void { export function accountPassword(oldPassword: string, newPassword: string, hashedNewPassword: string): void {
const cmd = create(Data.Command_AccountPasswordSchema, { oldPassword, newPassword, hashedNewPassword }); const cmd = create(Command_AccountPasswordSchema, { oldPassword, newPassword, hashedNewPassword });
WebClient.instance.protobuf.sendSessionCommand(Data.Command_AccountPassword_ext, cmd, { WebClient.instance.protobuf.sendSessionCommand(Command_AccountPassword_ext, cmd, {
onSuccess: () => { onSuccess: () => {
WebClient.instance.response.session.accountPasswordChange(); WebClient.instance.response.session.accountPasswordChange();
}, },

View file

@ -1,32 +1,37 @@
import { App, Enriched, Data } from '@app/types';
import { create } from '@bufbuild/protobuf'; import { create } from '@bufbuild/protobuf';
import {
Command_Activate_ext,
Command_ActivateSchema,
Response_ResponseCode,
type ActivateParams,
} from '@app/generated';
import { StatusEnum } from '../../StatusEnum';
import { CLIENT_CONFIG } from '../../config'; import { CLIENT_CONFIG } from '../../config';
import { WebClient } from '../../WebClient'; import { WebClient } from '../../WebClient';
import type { ConnectTarget } from '../../WebClientConfig';
import { disconnect, login, updateStatus } from './'; import { disconnect, login, updateStatus } from './';
export function activate(options: Omit<Enriched.ActivateConnectOptions, 'password'>, password?: string, passwordSalt?: string): void { export function activate(options: ConnectTarget & ActivateParams, password?: string, passwordSalt?: string): void {
const { userName, token } = options; const { userName, token } = options;
WebClient.instance.protobuf.sendSessionCommand(Data.Command_Activate_ext, create(Data.Command_ActivateSchema, { WebClient.instance.protobuf.sendSessionCommand(Command_Activate_ext, create(Command_ActivateSchema, {
...CLIENT_CONFIG, ...CLIENT_CONFIG,
userName, userName,
token, token,
}), { }), {
onResponseCode: { onResponseCode: {
[Data.Response_ResponseCode.RespActivationAccepted]: () => { [Response_ResponseCode.RespActivationAccepted]: () => {
WebClient.instance.response.session.accountActivationSuccess(); WebClient.instance.response.session.accountActivationSuccess();
login({ login({
host: options.host, host: options.host,
port: options.port, port: options.port,
userName: options.userName, userName: options.userName,
reason: App.WebSocketConnectReason.LOGIN,
}, password, passwordSalt); }, password, passwordSalt);
}, },
}, },
onError: () => { onError: () => {
updateStatus(App.StatusEnum.DISCONNECTED, 'Account Activation Failed'); updateStatus(StatusEnum.DISCONNECTED, 'Account Activation Failed');
disconnect(); disconnect();
WebClient.instance.response.session.accountActivationFailed(); WebClient.instance.response.session.accountActivationFailed();
}, },

View file

@ -1,7 +1,7 @@
import { create } from '@bufbuild/protobuf'; import { create } from '@bufbuild/protobuf';
import { WebClient } from '../../WebClient'; import { WebClient } from '../../WebClient';
import { Data } from '@app/types'; import { Command_AddToList_ext, Command_AddToListSchema } from '@app/generated';
export function addToBuddyList(userName: string): void { export function addToBuddyList(userName: string): void {
addToList('buddy', userName); addToList('buddy', userName);
@ -12,7 +12,7 @@ export function addToIgnoreList(userName: string): void {
} }
export function addToList(list: string, userName: string): void { export function addToList(list: string, userName: string): void {
WebClient.instance.protobuf.sendSessionCommand(Data.Command_AddToList_ext, create(Data.Command_AddToListSchema, { list, userName }), { WebClient.instance.protobuf.sendSessionCommand(Command_AddToList_ext, create(Command_AddToListSchema, { list, userName }), {
onSuccess: () => { onSuccess: () => {
WebClient.instance.response.session.addToList(list, userName); WebClient.instance.response.session.addToList(list, userName);
}, },

View file

@ -1,25 +1,10 @@
import { App, Enriched } from '@app/types';
import { WebClient } from '../../WebClient'; import { WebClient } from '../../WebClient';
import { updateStatus } from './'; import type { ConnectTarget } from '../../WebClientConfig';
export function connect(options: Enriched.WebSocketConnectOptions): void { export function connect(target: ConnectTarget): void {
switch (options.reason) { WebClient.instance.connect(target);
case App.WebSocketConnectReason.LOGIN: }
case App.WebSocketConnectReason.REGISTER:
case App.WebSocketConnectReason.ACTIVATE_ACCOUNT: export function testConnect(target: ConnectTarget): void {
case App.WebSocketConnectReason.PASSWORD_RESET_REQUEST: WebClient.instance.testConnect(target);
case App.WebSocketConnectReason.PASSWORD_RESET_CHALLENGE:
case App.WebSocketConnectReason.PASSWORD_RESET:
updateStatus(App.StatusEnum.CONNECTING, 'Connecting...');
WebClient.instance.connect(options);
return;
case App.WebSocketConnectReason.TEST_CONNECTION:
WebClient.instance.testConnect(options);
return;
default: {
const { reason } = options as Enriched.WebSocketConnectOptions;
updateStatus(App.StatusEnum.DISCONNECTED, `Unknown Connection Attempt: ${reason}`);
return;
}
}
} }

View file

@ -1,10 +1,10 @@
import { create } from '@bufbuild/protobuf'; import { create } from '@bufbuild/protobuf';
import { WebClient } from '../../WebClient'; import { WebClient } from '../../WebClient';
import { Data } from '@app/types'; import { Command_DeckDel_ext, Command_DeckDelSchema } from '@app/generated';
export function deckDel(deckId: number): void { export function deckDel(deckId: number): void {
WebClient.instance.protobuf.sendSessionCommand(Data.Command_DeckDel_ext, create(Data.Command_DeckDelSchema, { deckId }), { WebClient.instance.protobuf.sendSessionCommand(Command_DeckDel_ext, create(Command_DeckDelSchema, { deckId }), {
onSuccess: () => { onSuccess: () => {
WebClient.instance.response.session.deleteServerDeck(deckId); WebClient.instance.response.session.deleteServerDeck(deckId);
}, },

View file

@ -1,10 +1,10 @@
import { create } from '@bufbuild/protobuf'; import { create } from '@bufbuild/protobuf';
import { WebClient } from '../../WebClient'; import { WebClient } from '../../WebClient';
import { Data } from '@app/types'; import { Command_DeckDelDir_ext, Command_DeckDelDirSchema } from '@app/generated';
export function deckDelDir(path: string): void { export function deckDelDir(path: string): void {
WebClient.instance.protobuf.sendSessionCommand(Data.Command_DeckDelDir_ext, create(Data.Command_DeckDelDirSchema, { path }), { WebClient.instance.protobuf.sendSessionCommand(Command_DeckDelDir_ext, create(Command_DeckDelDirSchema, { path }), {
onSuccess: () => { onSuccess: () => {
WebClient.instance.response.session.deleteServerDeckDir(path); WebClient.instance.response.session.deleteServerDeckDir(path);
}, },

View file

@ -1,14 +1,14 @@
import { create } from '@bufbuild/protobuf'; import { create } from '@bufbuild/protobuf';
import { WebClient } from '../../WebClient'; import { WebClient } from '../../WebClient';
import { Data } from '@app/types'; import { Command_DeckDownload_ext, Command_DeckDownloadSchema, Response_DeckDownload_ext } from '@app/generated';
export function deckDownload(deckId: number): void { export function deckDownload(deckId: number): void {
WebClient.instance.protobuf.sendSessionCommand( WebClient.instance.protobuf.sendSessionCommand(
Data.Command_DeckDownload_ext, Command_DeckDownload_ext,
create(Data.Command_DeckDownloadSchema, { deckId }), create(Command_DeckDownloadSchema, { deckId }),
{ {
responseExt: Data.Response_DeckDownload_ext, responseExt: Response_DeckDownload_ext,
onSuccess: (response) => { onSuccess: (response) => {
WebClient.instance.response.session.downloadServerDeck(deckId, response); WebClient.instance.response.session.downloadServerDeck(deckId, response);
}, },

View file

@ -1,11 +1,11 @@
import { create } from '@bufbuild/protobuf'; import { create } from '@bufbuild/protobuf';
import { WebClient } from '../../WebClient'; import { WebClient } from '../../WebClient';
import { Data } from '@app/types'; import { Command_DeckList_ext, Command_DeckListSchema, Response_DeckList_ext } from '@app/generated';
export function deckList(): void { export function deckList(): void {
WebClient.instance.protobuf.sendSessionCommand(Data.Command_DeckList_ext, create(Data.Command_DeckListSchema), { WebClient.instance.protobuf.sendSessionCommand(Command_DeckList_ext, create(Command_DeckListSchema), {
responseExt: Data.Response_DeckList_ext, responseExt: Response_DeckList_ext,
onSuccess: (response) => { onSuccess: (response) => {
if (response.root) { if (response.root) {
WebClient.instance.response.session.updateServerDecks(response); WebClient.instance.response.session.updateServerDecks(response);

View file

@ -1,10 +1,10 @@
import { create } from '@bufbuild/protobuf'; import { create } from '@bufbuild/protobuf';
import { WebClient } from '../../WebClient'; import { WebClient } from '../../WebClient';
import { Data } from '@app/types'; import { Command_DeckNewDir_ext, Command_DeckNewDirSchema } from '@app/generated';
export function deckNewDir(path: string, dirName: string): void { export function deckNewDir(path: string, dirName: string): void {
WebClient.instance.protobuf.sendSessionCommand(Data.Command_DeckNewDir_ext, create(Data.Command_DeckNewDirSchema, { path, dirName }), { WebClient.instance.protobuf.sendSessionCommand(Command_DeckNewDir_ext, create(Command_DeckNewDirSchema, { path, dirName }), {
onSuccess: () => { onSuccess: () => {
WebClient.instance.response.session.createServerDeckDir(path, dirName); WebClient.instance.response.session.createServerDeckDir(path, dirName);
}, },

View file

@ -1,14 +1,14 @@
import { create } from '@bufbuild/protobuf'; import { create } from '@bufbuild/protobuf';
import { WebClient } from '../../WebClient'; import { WebClient } from '../../WebClient';
import { Data } from '@app/types'; import { Command_DeckUpload_ext, Command_DeckUploadSchema, Response_DeckUpload_ext } from '@app/generated';
export function deckUpload(path: string, deckId: number, deckList: string): void { export function deckUpload(path: string, deckId: number, deckList: string): void {
WebClient.instance.protobuf.sendSessionCommand( WebClient.instance.protobuf.sendSessionCommand(
Data.Command_DeckUpload_ext, Command_DeckUpload_ext,
create(Data.Command_DeckUploadSchema, { path, deckId, deckList }), create(Command_DeckUploadSchema, { path, deckId, deckList }),
{ {
responseExt: Data.Response_DeckUpload_ext, responseExt: Response_DeckUpload_ext,
onSuccess: (response) => { onSuccess: (response) => {
if (response.newFile) { if (response.newFile) {
WebClient.instance.response.session.uploadServerDeck(path, response.newFile); WebClient.instance.response.session.uploadServerDeck(path, response.newFile);

Some files were not shown because too many files have changed in this diff Show more