diff --git a/webclient/buf.gen.plugin.mjs b/webclient/buf.gen.plugin.mjs index b618761db..35354ce4c 100644 --- a/webclient/buf.gen.plugin.mjs +++ b/webclient/buf.gen.plugin.mjs @@ -11,7 +11,8 @@ import { createEcmaScriptPlugin, runNodeJs } from '@bufbuild/protoplugin'; const HEADER = [ '// @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 */', '', '', @@ -55,6 +56,71 @@ const inner = createEcmaScriptPlugin({ } 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} */ + 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 // near-duplicate registry types and helpers that used to live in // src/websocket/services/protobuf-types.ts into one generic pair. diff --git a/webclient/eslint.boundaries.mjs b/webclient/eslint.boundaries.mjs index 0d67c6ae9..05bb6cf83 100644 --- a/webclient/eslint.boundaries.mjs +++ b/webclient/eslint.boundaries.mjs @@ -19,37 +19,49 @@ const types = (...types) => types.map((type) => ({ to: { type } })); const rules = [ { 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: 'api' }, allow: types('types', 'store', 'websocket') }, + { from: { type: 'api' }, allow: types('store', 'types', 'websocket') }, - { from: { type: 'hooks' }, allow: types('services', 'types') }, + { from: { type: 'hooks' }, allow: types('api', 'services', 'types', 'websocket') }, { from: { type: 'images' }, allow: types('types') }, { from: { type: 'services' }, allow: types('api', 'store', 'types') }, - { from: { type: 'components' }, allow: types('api', 'dialogs', 'forms', 'hooks', 'images', 'services', 'types', 'store') }, - { from: { type: 'containers' }, allow: types('api', 'components', 'dialogs', 'forms', 'hooks', 'images', 'services', 'types', 'store') }, - { from: { type: 'dialogs' }, allow: types('components', 'forms', 'hooks', 'services', 'types', 'store') }, - { from: { type: 'forms' }, allow: types('components', 'hooks', 'types', 'services', 'store') }, + { + from: { type: 'components' }, + allow: types('api', 'dialogs', 'forms', 'hooks', 'images', 'services', 'store', 'types') + }, + { + from: { type: 'containers' }, + allow: types('api', 'components', 'dialogs', 'forms', 'hooks', 'images', 'services', 'store', 'types') + }, + { from: { type: 'dialogs' }, allow: types('components', 'forms', 'hooks', 'services', 'store', 'types') }, + { from: { type: 'forms' }, allow: types('components', 'hooks', 'services', 'store', 'types') }, ]; -export const boundariesConfig = { - plugins: { boundaries }, - settings: { - 'boundaries/elements': elements, - 'import/resolver': { +export const boundariesConfig = [ + { + plugins: { boundaries }, + settings: { + 'boundaries/elements': elements, + 'import/resolver': { typescript: { - alwaysTryTypes: true, - project: './tsconfig.json', + alwaysTryTypes: true, + project: './tsconfig.json', }, + }, + }, + rules: { + 'boundaries/dependencies': ['error', { + default: 'disallow', + rules, + }], }, }, - rules: { - 'boundaries/dependencies': ['error', { - default: 'disallow', - rules, - }], + { + files: ['**/*.spec.*'], + rules: { 'boundaries/dependencies': 'off' }, }, -}; +]; diff --git a/webclient/eslint.config.mjs b/webclient/eslint.config.mjs index 28a00a0ae..f8885d58e 100644 --- a/webclient/eslint.config.mjs +++ b/webclient/eslint.config.mjs @@ -14,7 +14,7 @@ export default tseslint.config( ...tseslint.configs.recommended, // Enforce module boundaries - boundariesConfig, + ...boundariesConfig, // Project-specific config { diff --git a/webclient/integration/src/admin.spec.ts b/webclient/integration/src/admin.spec.ts new file mode 100644 index 000000000..f67bcfc6f --- /dev/null +++ b/webclient/integration/src/admin.spec.ts @@ -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, + }))); + }); +}); \ No newline at end of file diff --git a/webclient/integration/src/authentication.spec.ts b/webclient/integration/src/authentication.spec.ts index fb852b565..c5149c809 100644 --- a/webclient/integration/src/authentication.spec.ts +++ b/webclient/integration/src/authentication.spec.ts @@ -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 { describe, expect, it } from 'vitest'; -import { App, Data } from '@app/types'; +import { Data } from '@app/types'; import { store } from '@app/store'; +import { StatusEnum, WebSocketConnectReason } from '@app/websocket'; -import { connectAndHandshake } from './helpers/setup'; +import { connectAndHandshake, connectAndHandshakeWithSalt } from './helpers/setup'; import { buildResponse, buildResponseMessage, @@ -42,7 +44,7 @@ describe('authentication', () => { }))); 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.user?.name).toBe('alice'); expect(Object.keys(state.buddyList)).toEqual(['bob']); @@ -62,7 +64,7 @@ describe('authentication', () => { }))); 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.buddyList).toEqual({}); }); @@ -70,7 +72,7 @@ describe('authentication', () => { describe('register', () => { const registerOptions = { - reason: App.WebSocketConnectReason.REGISTER, + reason: WebSocketConnectReason.REGISTER as const, host: 'localhost', port: '4748', userName: 'newbie', @@ -78,10 +80,10 @@ describe('authentication', () => { email: 'newbie@example.com', country: 'US', realName: 'New Bie', - } as const; + }; it('auto-logs-in on RespRegistrationAccepted', () => { - connectAndHandshake(registerOptions as any); + connectAndHandshake(registerOptions); const register = findLastSessionCommand(Data.Command_Register_ext); expect(register.value.userName).toBe('newbie'); @@ -97,7 +99,7 @@ describe('authentication', () => { }); it('parks registration in awaiting-activation on RespRegistrationAcceptedNeedsActivation', () => { - connectAndHandshake(registerOptions as any); + connectAndHandshake(registerOptions); const register = findLastSessionCommand(Data.Command_Register_ext); deliverMessage(buildResponseMessage(buildResponse({ @@ -105,7 +107,7 @@ describe('authentication', () => { 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(); }); }); @@ -113,13 +115,13 @@ describe('authentication', () => { describe('activate', () => { it('auto-logs-in on RespActivationAccepted', () => { connectAndHandshake({ - reason: App.WebSocketConnectReason.ACTIVATE_ACCOUNT, + reason: WebSocketConnectReason.ACTIVATE_ACCOUNT as const, host: 'localhost', port: '4748', userName: 'alice', token: 'abc-123', password: 'secret', - } as any); + }); const activate = findLastSessionCommand(Data.Command_Activate_ext); expect(activate.value.userName).toBe('alice'); @@ -133,4 +135,43 @@ describe('authentication', () => { 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); + }); + }); }); diff --git a/webclient/integration/src/connection.spec.ts b/webclient/integration/src/connection.spec.ts index d81c35f49..31cd41b83 100644 --- a/webclient/integration/src/connection.spec.ts +++ b/webclient/integration/src/connection.spec.ts @@ -1,30 +1,44 @@ // 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. import { create } from '@bufbuild/protobuf'; import { describe, expect, it } from 'vitest'; -import { App, Data } from '@app/types'; +import { Data } from '@app/types'; import { store } from '@app/store'; +import { StatusEnum } from '@app/websocket'; 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 { buildSessionEventMessage, deliverMessage, } from './helpers/protobuf-builders'; import { findLastSessionCommand } from './helpers/command-capture'; -function loginOptions(overrides: Partial<{ userName: string; password: string }> = {}) { +function loginOptions(overrides: Partial<{ userName: string; password: string }> = {}): WebSocketConnectOptions { return { - reason: App.WebSocketConnectReason.LOGIN, + reason: WebSocketConnectReason.LOGIN, host: 'localhost', port: '4748', userName: overrides.userName ?? 'alice', password: overrides.password ?? 'secret', - } as const; + }; +} + +function connectWithOptions(opts: WebSocketConnectOptions): void { + setPendingOptions(opts); + getWebClient().connect({ host: opts.host, port: opts.port }); } function serverIdentification( @@ -43,47 +57,45 @@ function serverIdentification( describe('connection lifecycle', () => { it('flips status through CONNECTING → CONNECTED on socket open', () => { - getWebClient().connect(loginOptions()); + connectWithOptions(loginOptions()); expect(store.getState().server.status.connectionAttemptMade).toBe(true); 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'); }); it('routes a matching ServerIdentification into LOGGING_IN and sends Command_Login', () => { - getWebClient().connect(loginOptions({ userName: 'alice' })); + connectWithOptions(loginOptions({ userName: 'alice' })); openMockWebSocket(); 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.version).toBe('2.8.0'); const { value, cmdId } = findLastSessionCommand(Data.Command_Login_ext); expect(value.userName).toBe('alice'); expect(cmdId).toBeGreaterThan(0); - - expect(getWebClient().options).toBeNull(); }); it('disconnects on protocol version mismatch without sending a login command', () => { - getWebClient().connect(loginOptions()); + connectWithOptions(loginOptions()); openMockWebSocket(); deliverMessage(serverIdentification(PROTOCOL_VERSION + 1)); const mock = getMockWebSocket(); 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(); }); it('times out when onopen never fires within the keepalive window', () => { - getWebClient().connect(loginOptions()); + connectWithOptions(loginOptions()); const mock = getMockWebSocket(); expect(mock.close).not.toHaveBeenCalled(); @@ -91,11 +103,11 @@ describe('connection lifecycle', () => { vi.advanceTimersByTime(5000); 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', () => { - getWebClient().connect(loginOptions()); + connectWithOptions(loginOptions()); openMockWebSocket(); deliverMessage(serverIdentification()); @@ -103,6 +115,20 @@ describe('connection lifecycle', () => { getWebClient().disconnect(); 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); + }); +}); \ No newline at end of file diff --git a/webclient/integration/src/deck.spec.ts b/webclient/integration/src/deck.spec.ts new file mode 100644 index 000000000..76ed032be --- /dev/null +++ b/webclient/integration/src/deck.spec.ts @@ -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(); + }); +}); \ No newline at end of file diff --git a/webclient/integration/src/game.spec.ts b/webclient/integration/src/game.spec.ts new file mode 100644 index 000000000..eb88e6b2b --- /dev/null +++ b/webclient/integration/src/game.spec.ts @@ -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(); + }); +}); \ No newline at end of file diff --git a/webclient/integration/src/helpers/command-capture.ts b/webclient/integration/src/helpers/command-capture.ts index 0b48f4df6..b0d93cd2f 100644 --- a/webclient/integration/src/helpers/command-capture.ts +++ b/webclient/integration/src/helpers/command-capture.ts @@ -11,10 +11,12 @@ import { Data } from '@app/types'; 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 RoomCmd = Data.RoomCommand; type GameCmd = Data.GameCommand; +type AdminCmd = Data.AdminCommand; +type ModeratorCmd = Data.ModeratorCommand; /** Decode every CommandContainer sent through the mock socket so far. */ export function captureAllOutbound(): Data.CommandContainer[] { @@ -110,3 +112,47 @@ export function findLastGameCommand( `No outbound game command with extension ${ext.typeName} has been sent.` ); } + +/** Admin-scoped equivalent of {@link findLastSessionCommand}. */ +export function findLastAdminCommand( + ext: GenExtension +): { 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( + ext: GenExtension +): { 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.` + ); +} diff --git a/webclient/integration/src/helpers/setup.ts b/webclient/integration/src/helpers/setup.ts index 99d351311..8e4413d98 100644 --- a/webclient/integration/src/helpers/setup.ts +++ b/webclient/integration/src/helpers/setup.ts @@ -14,10 +14,16 @@ import { create } from '@bufbuild/protobuf'; import { afterEach, beforeEach, vi } from 'vitest'; import { ServerDispatch, RoomsDispatch, GameDispatch } from '@app/store'; -import { App, Data, Enriched } from '@app/types'; -import { WebClient } from '@app/websocket'; +import { Data } from '@app/types'; +import { + WebClient, + StatusEnum, + WebSocketConnectReason, + setPendingOptions, +} from '@app/websocket'; +import type { WebSocketConnectOptions } from '@app/websocket'; import { PROTOCOL_VERSION } from '../../../src/websocket/config'; -import { createWebClientResponse, createWebClientRequest } from '@app/api'; +import { createWebClientRequest, createWebClientResponse } from '@app/api'; import { buildResponse, @@ -27,6 +33,8 @@ import { } from './protobuf-builders'; import { findLastSessionCommand } from './command-capture'; +export { setPendingOptions }; + export interface MockWebSocketInstance { send: ReturnType; close: ReturnType; @@ -97,8 +105,7 @@ function resetAll(): void { } client.protobuf.resetCommands(); - client.options = null; - client.status = App.StatusEnum.DISCONNECTED; + client.status = StatusEnum.DISCONNECTED; ServerDispatch.clearStore(); RoomsDispatch.clearStore(); @@ -117,8 +124,8 @@ function resetAll(): void { // ── Shared connect helpers ────────────────────────────────────────────────── -const DEFAULT_LOGIN_OPTIONS: Enriched.LoginConnectOptions = { - reason: App.WebSocketConnectReason.LOGIN, +const DEFAULT_LOGIN_OPTIONS: WebSocketConnectOptions = { + reason: WebSocketConnectReason.LOGIN, host: 'localhost', port: '4748', userName: 'alice', @@ -126,14 +133,16 @@ const DEFAULT_LOGIN_OPTIONS: Enriched.LoginConnectOptions = { }; export function connectRaw( - overrides: Partial = {} + overrides: Partial = {} ): 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(); } export function connectAndHandshake( - overrides: Partial = {} + overrides: Partial = {} ): void { connectRaw(overrides); deliverMessage(buildSessionEventMessage( @@ -146,6 +155,21 @@ export function connectAndHandshake( )); } +export function connectAndHandshakeWithSalt( + overrides: Partial = {} +): 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 { connectAndHandshake({ userName }); @@ -172,7 +196,7 @@ installMockWebSocket(); beforeEach(() => { vi.useFakeTimers(); - new WebClient(createWebClientResponse(), createWebClientRequest()); + new WebClient(createWebClientRequest(), createWebClientResponse()); }); afterEach(() => { diff --git a/webclient/integration/src/keep-alive.spec.ts b/webclient/integration/src/keep-alive.spec.ts index 521d4dce9..c4889e0fc 100644 --- a/webclient/integration/src/keep-alive.spec.ts +++ b/webclient/integration/src/keep-alive.spec.ts @@ -2,8 +2,9 @@ import { describe, expect, it } from 'vitest'; -import { App, Data } from '@app/types'; +import { Data } from '@app/types'; import { store } from '@app/store'; +import { StatusEnum } from '@app/websocket'; import { connectRaw, getMockWebSocket } from './helpers/setup'; import { @@ -31,7 +32,7 @@ describe('keep-alive', () => { vi.advanceTimersByTime(5000); const second = findLastSessionCommand(Data.Command_Ping_ext); 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', () => { @@ -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(); }); @@ -55,11 +56,11 @@ describe('keep-alive', () => { vi.advanceTimersByTime(5000); 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); expect(getMockWebSocket().close).toHaveBeenCalled(); - expect(store.getState().server.status.state).toBe(App.StatusEnum.DISCONNECTED); + expect(store.getState().server.status.state).toBe(StatusEnum.DISCONNECTED); }); }); diff --git a/webclient/integration/src/moderator.spec.ts b/webclient/integration/src/moderator.spec.ts new file mode 100644 index 000000000..c90c3b026 --- /dev/null +++ b/webclient/integration/src/moderator.spec.ts @@ -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'); + }); +}); \ No newline at end of file diff --git a/webclient/integration/src/password-reset.spec.ts b/webclient/integration/src/password-reset.spec.ts new file mode 100644 index 000000000..ec842c3ec --- /dev/null +++ b/webclient/integration/src/password-reset.spec.ts @@ -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); + }); +}); \ No newline at end of file diff --git a/webclient/integration/src/rooms.spec.ts b/webclient/integration/src/rooms.spec.ts index 7e9ed519f..5bd776d08 100644 --- a/webclient/integration/src/rooms.spec.ts +++ b/webclient/integration/src/rooms.spec.ts @@ -1,11 +1,12 @@ // 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 { describe, expect, it } from 'vitest'; import { Data } from '@app/types'; import { store } from '@app/store'; +import { RoomCommands } from '@app/websocket'; import { connectAndHandshake } from './helpers/setup'; import { @@ -15,7 +16,8 @@ import { buildSessionEventMessage, deliverMessage, } 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<{ 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', () => { it('populates rooms state from Event_ListRooms', () => { connectAndHandshake(); @@ -81,18 +98,7 @@ describe('rooms', () => { it('appends a room chat message on Event_RoomSay', () => { connectAndHandshake(); - - 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 }) }), - }))); + setupJoinedRoom(1); const say = create(Data.Event_RoomSaySchema, { name: 'bob', @@ -109,18 +115,7 @@ describe('rooms', () => { it('updates the game list on Event_ListGames', () => { connectAndHandshake(); - - 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 }) }), - }))); + setupJoinedRoom(1); const game = create(Data.ServerInfo_GameSchema, { gameId: 42, @@ -137,4 +132,102 @@ describe('rooms', () => { expect(roomGames?.[42]?.info?.description).toBe('Test Game'); 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); + }); +}); \ No newline at end of file diff --git a/webclient/integration/src/server-events.spec.ts b/webclient/integration/src/server-events.spec.ts index faa42f11a..16ac3a6bf 100644 --- a/webclient/integration/src/server-events.spec.ts +++ b/webclient/integration/src/server-events.spec.ts @@ -4,8 +4,9 @@ import { create } from '@bufbuild/protobuf'; import { describe, expect, it } from 'vitest'; -import { App, Data } from '@app/types'; +import { Data } from '@app/types'; import { store } from '@app/store'; +import { StatusEnum } from '@app/websocket'; import { connectAndHandshake } from './helpers/setup'; import { @@ -72,7 +73,7 @@ describe('server events', () => { )); 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'); }); diff --git a/webclient/integration/src/users.spec.ts b/webclient/integration/src/users.spec.ts index 03005abba..36062963d 100644 --- a/webclient/integration/src/users.spec.ts +++ b/webclient/integration/src/users.spec.ts @@ -117,4 +117,4 @@ describe('users', () => { expect(messages.bob).toHaveLength(1); expect(messages.bob[0].message).toBe('hey bob'); }); -}); +}); \ No newline at end of file diff --git a/webclient/src/api/index.ts b/webclient/src/api/index.ts index 0acaf2d93..fb201a02a 100644 --- a/webclient/src/api/index.ts +++ b/webclient/src/api/index.ts @@ -1,35 +1,2 @@ -import { WebClient } from '@app/websocket'; -import type { IWebClientRequest } from '@app/websocket'; - export { createWebClientResponse } from './response'; export { createWebClientRequest } from './request'; - -/** - * UI-facing request surface. Each property is a lazy getter that resolves - * `WebClient.instance` at call time, so consumers can import this before the - * singleton is bootstrapped — it only needs to exist by the first actual call. - * - * 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 = { - 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; - }, -}; diff --git a/webclient/src/api/request/AuthenticationRequestImpl.ts b/webclient/src/api/request/AuthenticationRequestImpl.ts index 9157d736f..91788d862 100644 --- a/webclient/src/api/request/AuthenticationRequestImpl.ts +++ b/webclient/src/api/request/AuthenticationRequestImpl.ts @@ -1,34 +1,71 @@ -import { App, Enriched } from '@app/types'; -import type { IAuthenticationRequest } from '@app/websocket'; -import { SessionCommands } from '@app/websocket'; +import { + WebClient, + StatusEnum, + SessionCommands, + WebSocketConnectReason, + setPendingOptions, +} from '@app/websocket'; +import type { + IAuthenticationRequest, + AuthRequestMap, + LoginConnectOptions, + TestConnectionOptions, + RegisterConnectOptions, + ActivateConnectOptions, + PasswordResetRequestConnectOptions, + PasswordResetChallengeConnectOptions, + PasswordResetConnectOptions, +} from '@app/websocket'; -export class AuthenticationRequestImpl implements IAuthenticationRequest { - login(options: Omit): void { - SessionCommands.connect({ ...options, reason: App.WebSocketConnectReason.LOGIN }); +interface AppAuthRequestOverrides extends AuthRequestMap { + LoginParams: Omit; + ConnectTarget: Omit; + RegisterParams: Omit; + ActivateParams: Omit; + ForgotPasswordRequestParams: Omit; + ForgotPasswordChallengeParams: Omit; + ForgotPasswordResetParams: Omit; +} + +export class AuthenticationRequestImpl implements IAuthenticationRequest { + login(options: Omit): void { + setPendingOptions({ ...options, reason: WebSocketConnectReason.LOGIN }); + SessionCommands.updateStatus(StatusEnum.CONNECTING, 'Connecting...'); + WebClient.instance.connect({ host: options.host, port: options.port }); } - testConnection(options: Omit): void { - SessionCommands.connect({ ...options, reason: App.WebSocketConnectReason.TEST_CONNECTION }); + testConnection(options: Omit): void { + WebClient.instance.testConnect({ host: options.host, port: options.port }); } - register(options: Omit): void { - SessionCommands.connect({ ...options, reason: App.WebSocketConnectReason.REGISTER }); + register(options: Omit): void { + setPendingOptions({ ...options, reason: WebSocketConnectReason.REGISTER }); + SessionCommands.updateStatus(StatusEnum.CONNECTING, 'Connecting...'); + WebClient.instance.connect({ host: options.host, port: options.port }); } - activateAccount(options: Omit): void { - SessionCommands.connect({ ...options, reason: App.WebSocketConnectReason.ACTIVATE_ACCOUNT }); + activateAccount(options: Omit): void { + setPendingOptions({ ...options, reason: WebSocketConnectReason.ACTIVATE_ACCOUNT }); + SessionCommands.updateStatus(StatusEnum.CONNECTING, 'Connecting...'); + WebClient.instance.connect({ host: options.host, port: options.port }); } - resetPasswordRequest(options: Omit): void { - SessionCommands.connect({ ...options, reason: App.WebSocketConnectReason.PASSWORD_RESET_REQUEST }); + resetPasswordRequest(options: Omit): void { + setPendingOptions({ ...options, reason: WebSocketConnectReason.PASSWORD_RESET_REQUEST }); + SessionCommands.updateStatus(StatusEnum.CONNECTING, 'Connecting...'); + WebClient.instance.connect({ host: options.host, port: options.port }); } - resetPasswordChallenge(options: Omit): void { - SessionCommands.connect({ ...options, reason: App.WebSocketConnectReason.PASSWORD_RESET_CHALLENGE }); + resetPasswordChallenge(options: Omit): void { + setPendingOptions({ ...options, reason: WebSocketConnectReason.PASSWORD_RESET_CHALLENGE }); + SessionCommands.updateStatus(StatusEnum.CONNECTING, 'Connecting...'); + WebClient.instance.connect({ host: options.host, port: options.port }); } - resetPassword(options: Omit): void { - SessionCommands.connect({ ...options, reason: App.WebSocketConnectReason.PASSWORD_RESET }); + resetPassword(options: Omit): void { + setPendingOptions({ ...options, reason: WebSocketConnectReason.PASSWORD_RESET }); + SessionCommands.updateStatus(StatusEnum.CONNECTING, 'Connecting...'); + WebClient.instance.connect({ host: options.host, port: options.port }); } disconnect(): void { diff --git a/webclient/src/api/response/RoomResponseImpl.ts b/webclient/src/api/response/RoomResponseImpl.ts index 1a11bc4c5..d450158c0 100644 --- a/webclient/src/api/response/RoomResponseImpl.ts +++ b/webclient/src/api/response/RoomResponseImpl.ts @@ -1,8 +1,10 @@ -import { Data, Enriched } from '@app/types'; -import type { IRoomResponse } from '@app/websocket'; +import { Data } from '@app/types'; +import type { IRoomResponse, WebSocketRoomResponseOverrides } from '@app/websocket'; import { RoomsDispatch } from '@app/store'; -export class RoomResponseImpl implements IRoomResponse { +type Message = WebSocketRoomResponseOverrides['Event_RoomSay']; + +export class RoomResponseImpl implements IRoomResponse { clearStore(): void { RoomsDispatch.clearStore(); } @@ -23,7 +25,7 @@ export class RoomResponseImpl implements IRoomResponse { RoomsDispatch.updateGames(roomId, gameList); } - addMessage(roomId: number, message: Enriched.Message): void { + addMessage(roomId: number, message: Message): void { RoomsDispatch.addMessage(roomId, message); } diff --git a/webclient/src/api/response/SessionResponseImpl.ts b/webclient/src/api/response/SessionResponseImpl.ts index 7d6c16b47..59892d08d 100644 --- a/webclient/src/api/response/SessionResponseImpl.ts +++ b/webclient/src/api/response/SessionResponseImpl.ts @@ -1,8 +1,12 @@ -import { App, Data, Enriched } from '@app/types'; -import type { ISessionResponse } from '@app/websocket'; +import { Data } from '@app/types'; +import type { ISessionResponse, WebSocketSessionResponseOverrides } from '@app/websocket'; +import { StatusEnum } from '@app/websocket'; 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 { initialized(): void { ServerDispatch.initialized(); } @@ -15,7 +19,7 @@ export class SessionResponseImpl implements ISessionResponse { ServerDispatch.clearStore(); } - loginSuccessful(options: Enriched.LoginSuccessContext): void { + loginSuccessful(options: LoginSuccess): void { ServerDispatch.loginSuccessful(options); } @@ -63,8 +67,8 @@ export class SessionResponseImpl implements ISessionResponse { ServerDispatch.updateInfo(name, version); } - updateStatus(state: App.StatusEnum, description: string): void { - if (state === App.StatusEnum.DISCONNECTED) { + updateStatus(state: StatusEnum, description: string): void { + if (state === StatusEnum.DISCONNECTED) { GameDispatch.clearStore(); RoomsDispatch.clearStore(); ServerDispatch.clearStore(); @@ -92,7 +96,7 @@ export class SessionResponseImpl implements ISessionResponse { ServerDispatch.serverMessage(message); } - accountAwaitingActivation(options: Enriched.PendingActivationContext): void { + accountAwaitingActivation(options: PendingActivation): void { ServerDispatch.accountAwaitingActivation(options); } diff --git a/webclient/src/components/KnownHosts/KnownHosts.tsx b/webclient/src/components/KnownHosts/KnownHosts.tsx index 3efdf67ac..e233bc5ae 100644 --- a/webclient/src/components/KnownHosts/KnownHosts.tsx +++ b/webclient/src/components/KnownHosts/KnownHosts.tsx @@ -13,7 +13,7 @@ import AddIcon from '@mui/icons-material/Add'; import EditRoundedIcon from '@mui/icons-material/Edit'; import ErrorOutlinedIcon from '@mui/icons-material/ErrorOutlined'; -import { request } from '@app/api'; +import { useWebClient } from '@app/hooks'; import { KnownHostDialog } from '@app/dialogs'; import { useReduxEffect } from '@app/hooks'; import { HostDTO } from '@app/services'; @@ -64,6 +64,7 @@ const KnownHosts = (props) => { const { touched, error, warning } = meta; const { t } = useTranslation(); + const webClient = useWebClient(); const [hostsState, setHostsState] = useState({ hosts: [], @@ -197,7 +198,7 @@ const KnownHosts = (props) => { setTestingConnection(TestConnection.TESTING); const options = { ...App.getHostPort(hostsState.selectedHost) }; - request.authentication.testConnection(options); + webClient.request.authentication.testConnection(options); } return ( diff --git a/webclient/src/components/Message/Message.tsx b/webclient/src/components/Message/Message.tsx index bc9d640ec..bb5617242 100644 --- a/webclient/src/components/Message/Message.tsx +++ b/webclient/src/components/Message/Message.tsx @@ -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'; @@ -20,7 +20,7 @@ const ParsedMessage = ({ message }) => { const [messageChunks, setMessageChunks] = useState(null); const [name, setName] = useState(null); - useMemo(() => { + useEffect(() => { const name = message.match(App.MESSAGE_SENDER_REGEX); if (name) { diff --git a/webclient/src/components/UserDisplay/UserDisplay.tsx b/webclient/src/components/UserDisplay/UserDisplay.tsx index 6e38fab5c..c3d46446e 100644 --- a/webclient/src/components/UserDisplay/UserDisplay.tsx +++ b/webclient/src/components/UserDisplay/UserDisplay.tsx @@ -6,7 +6,7 @@ import Menu from '@mui/material/Menu'; import MenuItem from '@mui/material/MenuItem'; import { Images } from '@app/images'; -import { request } from '@app/api'; +import { useWebClient } from '@app/hooks'; import { ServerSelectors } from '@app/store'; import { App, Data } from '@app/types'; import { useAppSelector } from '@app/store'; @@ -18,6 +18,7 @@ const UserDisplay = ({ user }: UserDisplayProps) => { const buddyList = useAppSelector(state => ServerSelectors.getBuddyList(state)); const ignoreList = useAppSelector(state => ServerSelectors.getIgnoreList(state)); const [position, setPosition] = useState<{ x: number; y: number } | null>(null); + const webClient = useWebClient(); const { name, country } = user; @@ -32,19 +33,19 @@ const UserDisplay = ({ user }: UserDisplayProps) => { const isIgnored = Boolean(ignoreList[user.name]); const onAddBuddy = () => { - request.session.addToBuddyList(user.name); + webClient.request.session.addToBuddyList(user.name); handleClose(); }; const onRemoveBuddy = () => { - request.session.removeFromBuddyList(user.name); + webClient.request.session.removeFromBuddyList(user.name); handleClose(); }; const onAddIgnore = () => { - request.session.addToIgnoreList(user.name); + webClient.request.session.addToIgnoreList(user.name); handleClose(); }; const onRemoveIgnore = () => { - request.session.removeFromIgnoreList(user.name); + webClient.request.session.removeFromIgnoreList(user.name); handleClose(); }; diff --git a/webclient/src/containers/Account/Account.tsx b/webclient/src/containers/Account/Account.tsx index 05d0b1161..ee06f590a 100644 --- a/webclient/src/containers/Account/Account.tsx +++ b/webclient/src/containers/Account/Account.tsx @@ -7,7 +7,7 @@ import ListItemButton from '@mui/material/ListItemButton'; import Paper from '@mui/material/Paper'; import { UserDisplay, VirtualList, AuthGuard, LanguageDropdown } from '@app/components'; -import { request } from '@app/api'; +import { useWebClient } from '@app/hooks'; import { ServerSelectors } from '@app/store'; import Layout from '../Layout/Layout'; import { useAppSelector } from '@app/store'; @@ -23,17 +23,18 @@ const Account = () => { const serverName = useAppSelector(state => ServerSelectors.getName(state)); const serverVersion = useAppSelector(state => ServerSelectors.getVersion(state)); const user = useAppSelector(state => ServerSelectors.getUser(state)); + const webClient = useWebClient(); const { country, realName, name, userLevel, accountageSecs, avatarBmp } = user || {}; let url = URL.createObjectURL(new Blob([avatarBmp as BlobPart], { 'type': 'image/png' })); const { t } = useTranslation(); const handleAddToBuddies = ({ userName }) => { - request.session.addToBuddyList(userName); + webClient.request.session.addToBuddyList(userName); }; const handleAddToIgnore = ({ userName }) => { - request.session.addToIgnoreList(userName); + webClient.request.session.addToIgnoreList(userName); }; return ( diff --git a/webclient/src/containers/Layout/LeftNav.tsx b/webclient/src/containers/Layout/LeftNav.tsx index b9b2d377c..00d89455b 100644 --- a/webclient/src/containers/Layout/LeftNav.tsx +++ b/webclient/src/containers/Layout/LeftNav.tsx @@ -8,8 +8,8 @@ import CloseIcon from '@mui/icons-material/Close'; import MailOutlineRoundedIcon from '@mui/icons-material/MailOutlineRounded'; import MenuRoundedIcon from '@mui/icons-material/MenuRounded'; -import { request } from '@app/api'; import { CardImportDialog } from '@app/dialogs'; +import { useWebClient } from '@app/hooks'; import { Images } from '@app/images'; import { RoomsSelectors, ServerSelectors } from '@app/store'; import { App } from '@app/types'; @@ -28,6 +28,7 @@ const LeftNav = () => { const isConnected = useAppSelector(ServerSelectors.getIsConnected); const isModerator = useAppSelector(ServerSelectors.getIsUserModerator); const navigate = useNavigate(); + const webClient = useWebClient(); const [state, setState] = useState({ anchorEl: null, showCardImportDialog: false, @@ -66,7 +67,7 @@ const LeftNav = () => { const leaveRoom = (event, roomId) => { event.preventDefault(); - request.rooms.leaveRoom(roomId); + webClient.request.rooms.leaveRoom(roomId); }; const openImportCardWizard = () => { diff --git a/webclient/src/containers/Login/Login.tsx b/webclient/src/containers/Login/Login.tsx index 6727bf89d..70896ce77 100644 --- a/webclient/src/containers/Login/Login.tsx +++ b/webclient/src/containers/Login/Login.tsx @@ -6,11 +6,10 @@ import Button from '@mui/material/Button'; import Paper from '@mui/material/Paper'; import Typography from '@mui/material/Typography'; -import { request } from '@app/api'; import { RegistrationDialog, RequestPasswordResetDialog, ResetPasswordDialog, AccountActivationDialog } from '@app/dialogs'; import { LanguageDropdown } from '@app/components'; import { LoginForm } from '@app/forms'; -import { useReduxEffect, useFireOnce } from '@app/hooks'; +import { useReduxEffect, useFireOnce, useWebClient } from '@app/hooks'; import { Images } from '@app/images'; import { HostDTO, serverProps } from '@app/services'; import { App, Enriched } from '@app/types'; @@ -67,6 +66,7 @@ const Root = styled('div')(({ theme }) => ({ const Login = () => { const description = useAppSelector(s => ServerSelectors.getDescription(s)); const isConnected = useAppSelector(ServerSelectors.getIsConnected); + const webClient = useWebClient(); const { t } = useTranslation(); const [pendingActivationOptions, setPendingActivationOptions] = useState(null); @@ -134,7 +134,7 @@ const Login = () => { options.hashedPassword = selectedHost.hashedPassword; } - request.authentication.login(options); + webClient.request.authentication.login(options); }, []); const [submitButtonDisabled, resetSubmitButton, handleLogin] = useFireOnce(onSubmitLogin); @@ -153,7 +153,7 @@ const Login = () => { setRememberLogin(registerForm); const { userName, password, email, country, realName, selectedHost } = registerForm; - request.authentication.register({ + webClient.request.authentication.register({ ...App.getHostPort(selectedHost), userName, password, @@ -167,7 +167,7 @@ const Login = () => { if (!pendingActivationOptions) { return; } - request.authentication.activateAccount({ + webClient.request.authentication.activateAccount({ host: pendingActivationOptions.host, port: pendingActivationOptions.port, userName: pendingActivationOptions.userName, @@ -180,17 +180,17 @@ const Login = () => { const { host, port } = App.getHostPort(selectedHost); if (email) { - request.authentication.resetPasswordChallenge({ userName, email, host, port }); + webClient.request.authentication.resetPasswordChallenge({ userName, email, host, port }); } else { setUserToResetPassword(userName); - request.authentication.resetPasswordRequest({ userName, host, port }); + webClient.request.authentication.resetPasswordRequest({ userName, host, port }); } }; const handleResetPasswordDialogSubmit = ({ userName, token, newPassword, selectedHost }) => { const { host, port } = App.getHostPort(selectedHost); - request.authentication.resetPassword({ userName, token, newPassword, host, port }); + webClient.request.authentication.resetPassword({ userName, token, newPassword, host, port }); }; const skipTokenRequest = (userName) => { diff --git a/webclient/src/containers/Logs/Logs.tsx b/webclient/src/containers/Logs/Logs.tsx index 7d592b82c..8ab9a181b 100644 --- a/webclient/src/containers/Logs/Logs.tsx +++ b/webclient/src/containers/Logs/Logs.tsx @@ -1,9 +1,9 @@ // eslint-disable-next-line import React, { useEffect } from "react"; -import { request } from '@app/api'; import { AuthGuard, ModGuard } from '@app/components'; import { SearchForm } from '@app/forms'; +import { useWebClient } from '@app/hooks'; import { ServerDispatch, ServerSelectors } from '@app/store'; import { Data } from '@app/types'; import { useAppSelector } from '@app/store'; @@ -13,6 +13,7 @@ import './Logs.css'; const Logs = () => { const logs = useAppSelector(state => ServerSelectors.getLogs(state)); + const webClient = useWebClient(); const MAXIMUM_RESULTS = 1000; useEffect(() => { @@ -51,7 +52,7 @@ const Logs = () => { trimmedFields.maximumResults = MAXIMUM_RESULTS; if (required.length) { - request.moderator.viewLogHistory(trimmedFields); + webClient.request.moderator.viewLogHistory(trimmedFields); } else { // @TODO use yet-to-be-implemented banner/alert } diff --git a/webclient/src/containers/Room/Room.tsx b/webclient/src/containers/Room/Room.tsx index a0312ae94..5877c57b5 100644 --- a/webclient/src/containers/Room/Room.tsx +++ b/webclient/src/containers/Room/Room.tsx @@ -4,8 +4,8 @@ import { useNavigate, useParams, generatePath } from 'react-router-dom'; import ListItemButton from '@mui/material/ListItemButton'; import Paper from '@mui/material/Paper'; -import { request } from '@app/api'; import { ScrollToBottomOnChanges, ThreePaneLayout, UserDisplay, VirtualList, AuthGuard } from '@app/components'; +import { useWebClient } from '@app/hooks'; import { RoomsSelectors } from '@app/store'; import { useAppSelector } from '@app/store'; import { App } from '@app/types'; @@ -29,6 +29,7 @@ const Room = () => { const room = rooms[roomId]; const roomMessages = messages[roomId]; const users = useAppSelector(state => RoomsSelectors.getSortedRoomUsers(state, roomId)); + const webClient = useWebClient(); useEffect(() => { if (!joined.find(r => r.info.roomId === roomId)) { @@ -38,7 +39,7 @@ const Room = () => { const handleRoomSay = ({ message }) => { if (message) { - request.rooms.roomSay(roomId, message); + webClient.request.rooms.roomSay(roomId, message); } } diff --git a/webclient/src/containers/Server/Rooms.tsx b/webclient/src/containers/Server/Rooms.tsx index c4f16652c..1eecce729 100644 --- a/webclient/src/containers/Server/Rooms.tsx +++ b/webclient/src/containers/Server/Rooms.tsx @@ -9,20 +9,20 @@ import TableCell from '@mui/material/TableCell'; import TableHead from '@mui/material/TableHead'; import TableRow from '@mui/material/TableRow'; - -import { request } from '@app/api'; +import { useWebClient } from '@app/hooks'; import { App } from '@app/types'; import './Rooms.css'; const Rooms = ({ rooms, joinedRooms }) => { const navigate = useNavigate(); + const webClient = useWebClient(); function onClick(roomId) { if (joinedRooms.find(room => room.info.roomId === roomId)) { navigate(generatePath(App.RouteEnum.ROOM, { roomId })); } else { - request.rooms.joinRoom(roomId); + webClient.request.rooms.joinRoom(roomId); } } diff --git a/webclient/src/hooks/index.ts b/webclient/src/hooks/index.ts index b5e9cca55..a9385d50b 100644 --- a/webclient/src/hooks/index.ts +++ b/webclient/src/hooks/index.ts @@ -3,3 +3,4 @@ export * from './useFireOnce'; export * from './useDebounce'; export * from './useLocaleSort'; export * from './useReduxEffect'; +export * from './useWebClient'; diff --git a/webclient/src/hooks/useWebClient.tsx b/webclient/src/hooks/useWebClient.tsx new file mode 100644 index 000000000..469f03dc9 --- /dev/null +++ b/webclient/src/hooks/useWebClient.tsx @@ -0,0 +1,19 @@ +import { createContext, useContext, useState, ReactNode } from 'react'; +import { WebClient } from '@app/websocket'; +import { createWebClientRequest, createWebClientResponse } from '@app/api'; + +const WebClientContext = createContext(null); + +export function WebClientProvider({ children }: { children: ReactNode }) { + const [client] = useState(() => new WebClient(createWebClientRequest(), createWebClientResponse())); + + return {children}; +} + +export function useWebClient(): WebClient { + const client = useContext(WebClientContext); + if (!client) { + throw new Error('useWebClient must be used within a WebClientProvider'); + } + return client; +} diff --git a/webclient/src/index.tsx b/webclient/src/index.tsx index 57165f173..be51341a4 100644 --- a/webclient/src/index.tsx +++ b/webclient/src/index.tsx @@ -2,41 +2,29 @@ // creates the Redux store or connects to Redux DevTools. import './polyfills'; -import { StrictMode, useRef } from 'react'; +import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import { StyledEngineProvider } from '@mui/material'; import { ThemeProvider } from '@mui/material/styles'; -import { WebClient } from '@app/websocket'; -import { createWebClientResponse, createWebClientRequest } from '@app/api'; +import { WebClientProvider } from '@app/hooks'; import { AppShell } from '@app/containers'; import { materialTheme } from './material-theme'; import './i18n'; import './index.css'; -function initWebClient() { - const initialized = useRef(false); - - if (!initialized.current) { - initialized.current = true; - new WebClient(createWebClientResponse(), createWebClientRequest()); - } -} - const AppWithMaterialTheme = () => { - // Instantiate the WebClient singleton before any container renders or any - // hook touches WebClient.instance. - initWebClient(); - return ( - - - - - - - + + + + + + + + + ); } diff --git a/webclient/src/store/game/game.reducer.spec.ts b/webclient/src/store/game/game.reducer.spec.ts index ccf5c1fd7..3d4b8abb8 100644 --- a/webclient/src/store/game/game.reducer.spec.ts +++ b/webclient/src/store/game/game.reducer.spec.ts @@ -121,12 +121,12 @@ describe('2B: Game state & player management', () => { const state = makeState(); const result = gamesReducer(state, Actions.gameStateChanged({ gameId: 1, - data: { + data: create(Data.Event_GameStateChangedSchema, { gameStarted: true, activePlayerId: 3, activePhase: 2, secondsElapsed: 60, - }, + }), })); expect(result.games[1].started).toBe(true); @@ -394,7 +394,7 @@ describe('2C: CARD_MOVED', () => { expect(moved.providerId).toBe('new-prov'); }); - it('CARD_MOVED → returns newState (card removed from source) when targetZone does not exist on player', () => { + it('CARD_MOVED → no-ops when targetZone does not exist on player', () => { const { state } = stateWithCard(); const result = gamesReducer(state, Actions.cardMoved({ gameId: 1, @@ -414,7 +414,7 @@ describe('2C: CARD_MOVED', () => { newCardProviderId: '', }, })); - expect(cardsIn(result, 1, 1, 'hand')).toHaveLength(0); + expect(cardsIn(result, 1, 1, 'hand')).toHaveLength(1); expect(result.games[1].players[1].zones['nonexistent']).toBeUndefined(); }); }); @@ -850,7 +850,9 @@ describe('2I: Zone operations', () => { const result = gamesReducer(state, Actions.zonePropertiesChanged({ gameId: 1, playerId: 1, - data: { zoneName: 'hand', alwaysRevealTopCard: true, alwaysLookAtTopCard: true }, + data: create(Data.Event_ChangeZonePropertiesSchema, { + zoneName: 'hand', alwaysRevealTopCard: true, alwaysLookAtTopCard: true, + }), })); const zone = result.games[1].players[1].zones['hand']; diff --git a/webclient/src/store/game/game.reducer.ts b/webclient/src/store/game/game.reducer.ts index 3e854b48a..eff362c32 100644 --- a/webclient/src/store/game/game.reducer.ts +++ b/webclient/src/store/game/game.reducer.ts @@ -1,6 +1,6 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import { Data, Enriched } from '@app/types'; -import { create } from '@bufbuild/protobuf'; +import { create, isFieldSet } from '@bufbuild/protobuf'; import { GamesState } from './game.interfaces'; export const MAX_GAME_MESSAGES = 1000; @@ -129,16 +129,16 @@ export const gamesSlice = createSlice({ if (data.playerList?.length > 0) { game.players = normalizePlayers(data.playerList); } - if (data.gameStarted !== undefined && data.gameStarted !== null) { + if (isFieldSet(data, Data.Event_GameStateChangedSchema.field.gameStarted)) { game.started = data.gameStarted; } - if (data.activePlayerId !== undefined && data.activePlayerId !== null) { + if (isFieldSet(data, Data.Event_GameStateChangedSchema.field.activePlayerId)) { game.activePlayerId = data.activePlayerId; } - if (data.activePhase !== undefined && data.activePhase !== null) { + if (isFieldSet(data, Data.Event_GameStateChangedSchema.field.activePhase)) { game.activePhase = data.activePhase; } - if (data.secondsElapsed !== undefined) { + if (isFieldSet(data, Data.Event_GameStateChangedSchema.field.secondsElapsed)) { game.secondsElapsed = data.secondsElapsed; } }, @@ -201,6 +201,12 @@ export const gamesSlice = createSlice({ return; } + const targetPlayer = game.players[targetPlayerId]; + const targetZoneEntry = targetPlayer?.zones[targetZone]; + if (!targetPlayer || !targetZoneEntry) { + return; + } + let resolvedCardId = -1; if (cardId >= 0) { resolvedCardId = cardId; @@ -228,12 +234,6 @@ export const gamesSlice = createSlice({ } : buildEmptyCard(effectiveNewId, cardName, x, y, faceDown, newCardProviderId ?? ''); - const targetPlayer = game.players[targetPlayerId]; - const targetZoneEntry = targetPlayer?.zones[targetZone]; - if (!targetPlayer || !targetZoneEntry) { - return; - } - targetZoneEntry.order.push(movedCard.id); targetZoneEntry.byId[movedCard.id] = movedCard; targetZoneEntry.cardCount++; @@ -432,10 +432,10 @@ export const gamesSlice = createSlice({ if (!zone) { return; } - if (data.alwaysRevealTopCard !== undefined && data.alwaysRevealTopCard !== null) { + if (isFieldSet(data, Data.Event_ChangeZonePropertiesSchema.field.alwaysRevealTopCard)) { zone.alwaysRevealTopCard = data.alwaysRevealTopCard; } - if (data.alwaysLookAtTopCard !== undefined && data.alwaysLookAtTopCard !== null) { + if (isFieldSet(data, Data.Event_ChangeZonePropertiesSchema.field.alwaysLookAtTopCard)) { zone.alwaysLookAtTopCard = data.alwaysLookAtTopCard; } }, diff --git a/webclient/src/store/rooms/rooms.reducer.spec.ts b/webclient/src/store/rooms/rooms.reducer.spec.ts index 6bf8de4ca..ea09fefc7 100644 --- a/webclient/src/store/rooms/rooms.reducer.spec.ts +++ b/webclient/src/store/rooms/rooms.reducer.spec.ts @@ -114,12 +114,12 @@ describe('LEAVE_ROOM', () => { // ── ADD_MESSAGE ─────────────────────────────────────────────────────────────── describe('ADD_MESSAGE', () => { - it('appends message with timeReceived set', () => { + it('appends message preserving the timeReceived from the event handler', () => { const state = makeRoomsState({ messages: { 1: [] } }); - const message = makeMessage({ message: 'hello', timeReceived: 0 }); + const message = makeMessage({ message: 'hello', timeReceived: 1700000000000 }); const result = roomsReducer(state, Actions.addMessage({ roomId: 1, message })); expect(result.messages[1]).toHaveLength(1); - expect(result.messages[1][0].timeReceived).toBeGreaterThan(0); + expect(result.messages[1][0].timeReceived).toBe(1700000000000); }); it('creates message list for roomId when none exists', () => { diff --git a/webclient/src/store/rooms/rooms.reducer.tsx b/webclient/src/store/rooms/rooms.reducer.tsx index 3335dabc9..efd2c916d 100644 --- a/webclient/src/store/rooms/rooms.reducer.tsx +++ b/webclient/src/store/rooms/rooms.reducer.tsx @@ -69,14 +69,21 @@ export const roomsSlice = createSlice({ const { roomId } = action.payload; delete state.joinedRoomIds[roomId]; + delete state.joinedGameIds[roomId]; delete state.messages[roomId]; + + const room = state.rooms[roomId]; + if (room) { + room.games = {}; + room.users = {}; + } }, addMessage: (state, action: PayloadAction<{ roomId: number; message: Enriched.Message }>) => { const { roomId, message } = action.payload; const existing = state.messages[roomId] ?? []; - const normalized = normalizeUserMessage({ ...message, timeReceived: Date.now() }); + const normalized = normalizeUserMessage(message); const next = existing.length >= MAX_ROOM_MESSAGES ? [...existing.slice(existing.length - MAX_ROOM_MESSAGES + 1), normalized] diff --git a/webclient/src/store/rooms/rooms.selectors.spec.ts b/webclient/src/store/rooms/rooms.selectors.spec.ts index d1c2d8e02..adbbcfe04 100644 --- a/webclient/src/store/rooms/rooms.selectors.spec.ts +++ b/webclient/src/store/rooms/rooms.selectors.spec.ts @@ -1,6 +1,7 @@ import { Selectors } from './rooms.selectors'; import { RoomsState } from './rooms.interfaces'; import { makeGame, makeMessage, makeRoom, makeRoomsState, makeUser } from './__mocks__/rooms-fixtures'; +import { App } from '@app/types'; function rootState(rooms: RoomsState) { return { rooms }; @@ -111,13 +112,23 @@ describe('Selectors', () => { expect(Selectors.getRoomUsers(rootState(state), 1)).toBe(room.users); }); - it('getSortedRoomGames → returns sorted array view of games map', () => { - const game1 = makeGame({ gameId: 1, description: 'beta' }); - const game2 = makeGame({ gameId: 2, description: 'alpha' }); + it('getSortedRoomGames → returns games sorted by the active sort config', () => { + const game1 = makeGame({ gameId: 1, description: 'Beta' }); + const game2 = makeGame({ gameId: 2, description: 'Alpha' }); const room = makeRoom({ roomId: 1, games: { 1: game1, 2: game2 } }); - const state = makeRoomsState({ rooms: { 1: room } }); + const state = makeRoomsState({ + rooms: { 1: room }, + sortGamesBy: { field: 'info.description' as App.GameSortField, order: App.SortDirection.ASC }, + }); const result = Selectors.getSortedRoomGames(rootState(state), 1); expect(result).toHaveLength(2); + expect(result[0].info.description).toBe('Alpha'); + expect(result[1].info.description).toBe('Beta'); + }); + + it('getSortedRoomGames → returns EMPTY_GAMES for unknown roomId', () => { + const state = makeRoomsState({ rooms: {} }); + expect(Selectors.getSortedRoomGames(rootState(state), 999)).toHaveLength(0); }); it('getSortedRoomUsers → returns sorted user array sorted by name', () => { @@ -129,4 +140,40 @@ describe('Selectors', () => { expect(result[0].name).toBe('Alice'); expect(result[1].name).toBe('Zane'); }); + + it('getSortedRoomUsers → returns EMPTY_USERS for unknown roomId', () => { + const state = makeRoomsState({ rooms: {} }); + expect(Selectors.getSortedRoomUsers(rootState(state), 999)).toHaveLength(0); + }); + + // ── createSelector reference stability ────────────────────────────── + + it('getSortedRoomGames → returns same array reference for identical state', () => { + const game = makeGame({ gameId: 1 }); + const room = makeRoom({ roomId: 1, games: { 1: game } }); + const state = makeRoomsState({ rooms: { 1: room } }); + const root = rootState(state); + const a = Selectors.getSortedRoomGames(root, 1); + const b = Selectors.getSortedRoomGames(root, 1); + expect(a).toBe(b); + }); + + it('getSortedRoomUsers → returns same array reference for identical state', () => { + const user = makeUser({ name: 'Alice' }); + const room = makeRoom({ roomId: 1, users: { Alice: user } }); + const state = makeRoomsState({ rooms: { 1: room } }); + const root = rootState(state); + const a = Selectors.getSortedRoomUsers(root, 1); + const b = Selectors.getSortedRoomUsers(root, 1); + expect(a).toBe(b); + }); + + it('getJoinedRooms → returns same array reference for identical state', () => { + const room = makeRoom({ roomId: 1 }); + const state = makeRoomsState({ rooms: { 1: room }, joinedRoomIds: { 1: true } }); + const root = rootState(state); + const a = Selectors.getJoinedRooms(root); + const b = Selectors.getJoinedRooms(root); + expect(a).toBe(b); + }); }); diff --git a/webclient/src/store/rooms/rooms.selectors.tsx b/webclient/src/store/rooms/rooms.selectors.tsx index 82bb44b85..47e4b7d27 100644 --- a/webclient/src/store/rooms/rooms.selectors.tsx +++ b/webclient/src/store/rooms/rooms.selectors.tsx @@ -31,14 +31,18 @@ export const Selectors = { * Reads from the room's normalized `games` map — fixes the pre-existing * bug where this selector read from a never-populated top-level `games` field. */ - getJoinedGames: (state: State, roomId: number): Enriched.Game[] => { - const room = state.rooms.rooms[roomId]; - const joined = state.rooms.joinedGameIds[roomId]; - if (!room || !joined) { - return EMPTY_GAMES; + getJoinedGames: createSelector( + [ + (state: State, roomId: number) => state.rooms.rooms[roomId]?.games, + (state: State, roomId: number) => state.rooms.joinedGameIds[roomId], + ], + (games, joined): Enriched.Game[] => { + if (!games || !joined) { + return EMPTY_GAMES; + } + return Object.values(games).filter(game => joined[game.info.gameId]); } - return Object.values(room.games).filter(game => joined[game.info.gameId]); - }, + ), getRoomMessages: (state: State, roomId: number) => state.rooms.messages[roomId], diff --git a/webclient/src/store/server/server.interfaces.ts b/webclient/src/store/server/server.interfaces.ts index 4f3e05ca9..2a4e1ca5a 100644 --- a/webclient/src/store/server/server.interfaces.ts +++ b/webclient/src/store/server/server.interfaces.ts @@ -36,7 +36,7 @@ export interface ServerState { backendDecks: Data.Response_DeckList | null; downloadedDeck: { deckId: number; deck: string } | null; downloadedReplay: { replayId: number; replayData: Uint8Array } | null; - gamesOfUser: { [userName: string]: Enriched.Game[] }; + gamesOfUser: { [userName: string]: { [gameId: number]: Enriched.Game } }; registrationError: string | null; } diff --git a/webclient/src/store/server/server.reducer.spec.ts b/webclient/src/store/server/server.reducer.spec.ts index 33bfc42ff..99ac79e97 100644 --- a/webclient/src/store/server/server.reducer.spec.ts +++ b/webclient/src/store/server/server.reducer.spec.ts @@ -648,29 +648,29 @@ describe('Deck Storage', () => { // ── GAMES_OF_USER ───────────────────────────────────────────────────────────── describe('GAMES_OF_USER', () => { - it('stores normalized games keyed by userName', () => { + it('stores normalized games keyed by userName and gameId', () => { const response = create(Data.Response_GetGamesOfUserSchema, { gameList: [create(Data.ServerInfo_GameSchema, { gameId: 5, description: '' })], roomList: [], }); const state = makeServerState(); const result = serverReducer(state, Actions.gamesOfUser({ userName: 'alice', response })); - expect(result.gamesOfUser['alice']).toEqual([makeGame({ gameId: 5 })]); + expect(result.gamesOfUser['alice']).toEqual({ 5: makeGame({ gameId: 5 }) }); }); it('overwrites previous games for same user', () => { - const old = [makeGame({ gameId: 1 })]; + const old = { 1: makeGame({ gameId: 1 }) }; const response = create(Data.Response_GetGamesOfUserSchema, { gameList: [create(Data.ServerInfo_GameSchema, { gameId: 2, description: '' })], roomList: [], }); const state = makeServerState({ gamesOfUser: { alice: old } }); const result = serverReducer(state, Actions.gamesOfUser({ userName: 'alice', response })); - expect(result.gamesOfUser['alice']).toEqual([makeGame({ gameId: 2 })]); + expect(result.gamesOfUser['alice']).toEqual({ 2: makeGame({ gameId: 2 }) }); }); it('does not affect other users\' entries', () => { - const bobGames = [makeGame({ gameId: 3 })]; + const bobGames = { 3: makeGame({ gameId: 3 }) }; const response = create(Data.Response_GetGamesOfUserSchema, { gameList: [], roomList: [] }); const state = makeServerState({ gamesOfUser: { bob: bobGames } }); const result = serverReducer(state, Actions.gamesOfUser({ userName: 'alice', response })); diff --git a/webclient/src/store/server/server.reducer.ts b/webclient/src/store/server/server.reducer.ts index 3fa13ce71..a7e7682fa 100644 --- a/webclient/src/store/server/server.reducer.ts +++ b/webclient/src/store/server/server.reducer.ts @@ -1,5 +1,5 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; -import { App, Data } from '@app/types'; +import { App, Data, Enriched } from '@app/types'; import { create } from '@bufbuild/protobuf'; import { normalizeBannedUserError, normalizeGameObject, normalizeGametypeMap, normalizeLogs } from '../common'; @@ -179,8 +179,10 @@ export const serverSlice = createSlice({ } }, - updateUser: (state, action: PayloadAction<{ user: Data.ServerInfo_User | Partial }>) => { - state.user = { ...state.user, ...action.payload.user } as Data.ServerInfo_User; + updateUser: (state, action: PayloadAction<{ user: Partial }>) => { + state.user = state.user + ? { ...state.user, ...action.payload.user } as Data.ServerInfo_User + : action.payload.user as Data.ServerInfo_User; }, updateUsers: (state, action: PayloadAction<{ users: Data.ServerInfo_User[] }>) => { @@ -356,8 +358,12 @@ export const serverSlice = createSlice({ const gametypeMap = normalizeGametypeMap( (response.roomList ?? []).flatMap(room => room.gametypeList ?? []) ); - const normalizedGames = (response.gameList ?? []).map(g => normalizeGameObject(g, gametypeMap)); - state.gamesOfUser[userName] = normalizedGames; + const games: { [gameId: number]: Enriched.Game } = {}; + for (const g of response.gameList ?? []) { + const normalized = normalizeGameObject(g, gametypeMap); + games[normalized.info.gameId] = normalized; + } + state.gamesOfUser[userName] = games; }, registrationFailed: (state, action: PayloadAction<{ reason: string; endTime?: number }>) => { diff --git a/webclient/src/store/server/server.selectors.spec.ts b/webclient/src/store/server/server.selectors.spec.ts index 61917e580..8d9a75097 100644 --- a/webclient/src/store/server/server.selectors.spec.ts +++ b/webclient/src/store/server/server.selectors.spec.ts @@ -6,7 +6,7 @@ import { makeServerState, makeUser, } from './__mocks__/server-fixtures'; -import { App } from '@app/types'; +import { App, Data } from '@app/types'; function rootState(server: ServerState) { return { server }; @@ -149,4 +149,86 @@ describe('Selectors', () => { const state = makeServerState({ registrationError: 'bad input' }); expect(Selectors.getRegistrationError(rootState(state))).toBe('bad input'); }); + + // ── derived selectors (createSelector) ────────────────────────────── + + it('getIsConnected → true when state is LOGGED_IN', () => { + const state = makeServerState({ status: { connectionAttemptMade: true, state: App.StatusEnum.LOGGED_IN, description: null } }); + expect(Selectors.getIsConnected(rootState(state))).toBe(true); + }); + + it('getIsConnected → false when state is CONNECTED', () => { + const state = makeServerState({ status: { connectionAttemptMade: true, state: App.StatusEnum.CONNECTED, description: null } }); + expect(Selectors.getIsConnected(rootState(state))).toBe(false); + }); + + it('getIsConnected → false when state is DISCONNECTED', () => { + const state = makeServerState({ status: { connectionAttemptMade: false, state: App.StatusEnum.DISCONNECTED, description: null } }); + expect(Selectors.getIsConnected(rootState(state))).toBe(false); + }); + + it('getIsUserModerator → true when user has IsModerator flag', () => { + const Flag = Data.ServerInfo_User_UserLevelFlag; + const user = makeUser({ userLevel: Flag.IsUser | Flag.IsModerator }); + const state = makeServerState({ user }); + expect(Selectors.getIsUserModerator(rootState(state))).toBe(true); + }); + + it('getIsUserModerator → false when user lacks IsModerator flag', () => { + const Flag = Data.ServerInfo_User_UserLevelFlag; + const user = makeUser({ userLevel: Flag.IsUser | Flag.IsRegistered }); + const state = makeServerState({ user }); + expect(Selectors.getIsUserModerator(rootState(state))).toBe(false); + }); + + it('getIsUserModerator → false when user is null', () => { + const state = makeServerState({ user: null }); + expect(Selectors.getIsUserModerator(rootState(state))).toBe(false); + }); + + // ── createSelector reference stability ────────────────────────────── + + it('getIsConnected → returns same value reference for identical state', () => { + const state = makeServerState({ status: { connectionAttemptMade: true, state: App.StatusEnum.LOGGED_IN, description: null } }); + const root = rootState(state); + const a = Selectors.getIsConnected(root); + const b = Selectors.getIsConnected(root); + expect(a).toBe(b); + }); + + it('getSortedUsers → returns same array reference for identical state', () => { + const users = { Alice: makeUser({ name: 'Alice' }), Bob: makeUser({ name: 'Bob' }) }; + const state = makeServerState({ users }); + const root = rootState(state); + const a = Selectors.getSortedUsers(root); + const b = Selectors.getSortedUsers(root); + expect(a).toBe(b); + }); + + it('getSortedBuddyList → returns same array reference for identical state', () => { + const buddyList = { Alice: makeUser({ name: 'Alice' }) }; + const state = makeServerState({ buddyList }); + const root = rootState(state); + const a = Selectors.getSortedBuddyList(root); + const b = Selectors.getSortedBuddyList(root); + expect(a).toBe(b); + }); + + it('getSortedIgnoreList → returns same array reference for identical state', () => { + const ignoreList = { Troll: makeUser({ name: 'Troll' }) }; + const state = makeServerState({ ignoreList }); + const root = rootState(state); + const a = Selectors.getSortedIgnoreList(root); + const b = Selectors.getSortedIgnoreList(root); + expect(a).toBe(b); + }); + + it('getReplaysList → returns same array reference for identical state', () => { + const replays = { 1: makeReplayMatch({ gameId: 1 }) }; + const state = makeServerState({ replays }); + const root = rootState(state); + const a = Selectors.getReplaysList(root); + const b = Selectors.getReplaysList(root); + expect(a).toBe(b); + }); }); diff --git a/webclient/src/types/enriched.ts b/webclient/src/types/enriched.ts index c0033d3c2..b111c0e5e 100644 --- a/webclient/src/types/enriched.ts +++ b/webclient/src/types/enriched.ts @@ -11,8 +11,6 @@ import type { ServerInfo_User, } from '@app/generated'; -import { WebSocketConnectReason } from './server'; - // ── Domain model types (composition: raw proto + client-side fields) ────────── // // `info` holds the proto snapshot verbatim. Normalized/client-only fields @@ -133,84 +131,20 @@ export interface LogGroups { chat: ServerInfo_ChatMessage[]; } -// ── Connect options ─────────────────────────────────────────────────────────── -// Each variant is the enriched input for one session flow: the network -// transport fields (host/port) + the subset of proto Command_* fields the UI -// actually produces (user-entered credentials, tokens, email, etc.) + a -// `reason` discriminator so the websocket layer can route. -// -// Hand-written instead of `MessageInitShape & ...` -// because MessageInitShape is a `Message | { initShape }` union which -// collapses to the Message branding when intersected, requiring `$typeName` -// on literals. Keep these in sync with the corresponding proto command by -// convention; fields here map 1:1 to Command_* members. +// ── Connect options (re-exported from @app/websocket) ──────────────────────── +// Source of truth lives in src/websocket/connectOptions.ts. Re-exported here +// so UI code can use the Enriched.* namespace without importing @app/websocket. -interface ConnectTransport { - host: string; - port: string; - keepalive?: number; - autojoinrooms?: boolean; - clientid?: string; -} - -export interface LoginConnectOptions extends ConnectTransport { - reason: WebSocketConnectReason.LOGIN; - userName: string; - password?: string; - hashedPassword?: string; -} - -export interface RegisterConnectOptions extends ConnectTransport { - reason: WebSocketConnectReason.REGISTER; - userName: string; - password: string; - email: string; - country: string; - realName: string; -} - -export interface ActivateConnectOptions extends ConnectTransport { - reason: WebSocketConnectReason.ACTIVATE_ACCOUNT; - userName: string; - token: string; - /** Plaintext password carried through so post-activation auto-login can hash it. */ - password?: string; -} - -export interface PasswordResetRequestConnectOptions extends ConnectTransport { - reason: WebSocketConnectReason.PASSWORD_RESET_REQUEST; - userName: string; -} - -export interface PasswordResetChallengeConnectOptions extends ConnectTransport { - reason: WebSocketConnectReason.PASSWORD_RESET_CHALLENGE; - userName: string; - email: string; -} - -export interface PasswordResetConnectOptions extends ConnectTransport { - reason: WebSocketConnectReason.PASSWORD_RESET; - userName: string; - token: string; - newPassword: string; -} - -/** - * Test connection has no proto command — it just opens and closes a socket to - * verify reachability. - */ -export interface TestConnectionOptions extends ConnectTransport { - reason: WebSocketConnectReason.TEST_CONNECTION; -} - -export type WebSocketConnectOptions = - | LoginConnectOptions - | RegisterConnectOptions - | ActivateConnectOptions - | PasswordResetRequestConnectOptions - | PasswordResetChallengeConnectOptions - | PasswordResetConnectOptions - | TestConnectionOptions; +export type { + LoginConnectOptions, + RegisterConnectOptions, + ActivateConnectOptions, + PasswordResetRequestConnectOptions, + PasswordResetChallengeConnectOptions, + PasswordResetConnectOptions, + TestConnectionOptions, + WebSocketConnectOptions, +} from '@app/websocket'; /** * Context preserved through the ACCOUNT_AWAITING_ACTIVATION signal so the diff --git a/webclient/src/types/server.ts b/webclient/src/types/server.ts index 6c3fa8006..19135bc8f 100644 --- a/webclient/src/types/server.ts +++ b/webclient/src/types/server.ts @@ -1,27 +1,11 @@ +export { StatusEnum, WebSocketConnectReason } from '@app/websocket'; +import type { StatusEnum } from '@app/websocket'; + export interface ServerStatus { status: StatusEnum; description: string; } -export enum StatusEnum { - DISCONNECTED, - CONNECTING, - CONNECTED, - LOGGING_IN, - LOGGED_IN, - DISCONNECTING = 99 -} - -export enum WebSocketConnectReason { - LOGIN, - REGISTER, - ACTIVATE_ACCOUNT, - PASSWORD_RESET_REQUEST, - PASSWORD_RESET_CHALLENGE, - PASSWORD_RESET, - TEST_CONNECTION, -} - export class Host { id?: number; name: string; diff --git a/webclient/src/websocket/WebClient.spec.ts b/webclient/src/websocket/WebClient.spec.ts index 0c5010ec1..2fab4d695 100644 --- a/webclient/src/websocket/WebClient.spec.ts +++ b/webclient/src/websocket/WebClient.spec.ts @@ -17,8 +17,8 @@ vi.mock('./services/WebSocketService', () => ({ })); vi.mock('./services/ProtobufService', () => ({ - ProtobufService: vi.fn().mockImplementation(function ProtobufServiceImpl(options: SocketTransport) { - captured.pbOptions = options; + ProtobufService: vi.fn().mockImplementation(function ProtobufServiceImpl(transport: SocketTransport) { + captured.pbOptions = transport; return { handleMessageEvent: vi.fn(), resetCommands: vi.fn(), @@ -26,20 +26,16 @@ vi.mock('./services/ProtobufService', () => ({ }), })); -vi.mock('./commands/session', () => ({ - ping: vi.fn(), -})); - import { WebClient } from './WebClient'; import { WebSocketService } from './services/WebSocketService'; import { ProtobufService } from './services/ProtobufService'; -import { ping } from './commands/session'; -import { App, Enriched } from '@app/types'; +import { StatusEnum } from './interfaces/StatusEnum'; import { Subject } from 'rxjs'; import { Mock } from 'vitest'; import { SocketTransport } from './services/ProtobufService'; import { WebSocketServiceConfig } from './services/WebSocketService'; import type { IWebClientResponse, IWebClientRequest } from './interfaces'; +import type { ConnectTarget } from './interfaces/WebClientConfig'; import { installMockWebSocket } from './__mocks__/helpers'; function makeMockResponse(): IWebClientResponse { @@ -47,8 +43,11 @@ function makeMockResponse(): IWebClientResponse { session: { initialized: vi.fn(), connectionAttempted: vi.fn(), + connectionFailed: vi.fn(), clearStore: vi.fn(), updateStatus: vi.fn(), + testConnectionSuccessful: vi.fn(), + testConnectionFailed: vi.fn(), }, room: { clearStore: vi.fn() }, game: { clearStore: vi.fn() }, @@ -58,13 +57,7 @@ function makeMockResponse(): IWebClientResponse { } function makeMockRequest(): IWebClientRequest { - return { - authentication: {}, - session: {}, - rooms: {}, - admin: {}, - moderator: {}, - } as unknown as IWebClientRequest; + return {} as IWebClientRequest; } describe('WebClient', () => { @@ -74,11 +67,10 @@ describe('WebClient', () => { let messageSubject: Subject; beforeEach(() => { - // Reset the singleton so each test starts fresh. (WebClient as unknown as { _instance: WebClient | null })._instance = null; - (ProtobufService as Mock).mockImplementation(function ProtobufServiceImpl(options: SocketTransport) { - captured.pbOptions = options; + (ProtobufService as Mock).mockImplementation(function ProtobufServiceImpl(transport: SocketTransport) { + captured.pbOptions = transport; return { handleMessageEvent: vi.fn(), resetCommands: vi.fn(), @@ -99,7 +91,7 @@ describe('WebClient', () => { mockResponse = makeMockResponse(); mockRequest = makeMockRequest(); - client = new WebClient(mockResponse, mockRequest); + client = new WebClient(mockRequest, mockResponse); }); afterEach(() => { @@ -108,9 +100,9 @@ describe('WebClient', () => { }); describe('constructor', () => { - it('stores the response and request on the instance', () => { - expect(client.response).toBe(mockResponse); + it('stores the request and response on the instance', () => { expect(client.request).toBe(mockRequest); + expect(client.response).toBe(mockResponse); }); it('subscribes socket.message$ to protobuf.handleMessageEvent', () => { @@ -128,7 +120,7 @@ describe('WebClient', () => { }); it('throws when instantiated more than once', () => { - expect(() => new WebClient(makeMockResponse(), makeMockRequest())).toThrow(/singleton/); + expect(() => new WebClient(makeMockRequest(), makeMockResponse())).toThrow(/singleton/); }); }); @@ -141,16 +133,15 @@ describe('WebClient', () => { describe('connect', () => { it('calls response.session.connectionAttempted', () => { - const opts: Enriched.WebSocketConnectOptions = { host: 'h', port: '1', reason: App.WebSocketConnectReason.LOGIN, userName: 'u' }; - client.connect(opts); + const target: ConnectTarget = { host: 'h', port: '1' }; + client.connect(target); expect(mockResponse.session.connectionAttempted).toHaveBeenCalled(); }); - it('stores options and calls socket.connect', () => { - const opts: Enriched.WebSocketConnectOptions = { host: 'h', port: '1', reason: App.WebSocketConnectReason.LOGIN, userName: 'u' }; - client.connect(opts); - expect(client.options).toBe(opts); - expect(client.socket.connect).toHaveBeenCalledWith(opts); + it('calls socket.connect with target', () => { + const target: ConnectTarget = { host: 'h', port: '1' }; + client.connect(target); + expect(client.socket.connect).toHaveBeenCalledWith(target); }); }); @@ -172,30 +163,28 @@ describe('WebClient', () => { 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', () => { - client.testConnect(opts); + client.testConnect(target); expect(MockWS).toHaveBeenCalledWith(expect.stringContaining('://h:1')); }); it('calls testConnectionSuccessful and closes on open', () => { - (mockResponse.session as any).testConnectionSuccessful = vi.fn(); - client.testConnect(opts); + client.testConnect(target); wsMockInstance.onopen(); - expect((mockResponse.session as any).testConnectionSuccessful).toHaveBeenCalled(); + expect(mockResponse.session.testConnectionSuccessful).toHaveBeenCalled(); expect(wsMockInstance.close).toHaveBeenCalled(); }); it('calls testConnectionFailed on error', () => { - (mockResponse.session as any).testConnectionFailed = vi.fn(); - client.testConnect(opts); + client.testConnect(target); wsMockInstance.onerror(); - expect((mockResponse.session as any).testConnectionFailed).toHaveBeenCalled(); + expect(mockResponse.session.testConnectionFailed).toHaveBeenCalled(); }); it('closes socket after keepalive timeout', () => { - client.testConnect(opts); + client.testConnect(target); vi.advanceTimersByTime(5000); expect(wsMockInstance.close).toHaveBeenCalled(); }); @@ -210,32 +199,36 @@ describe('WebClient', () => { describe('updateStatus', () => { it('sets the status', () => { - client.updateStatus(App.StatusEnum.CONNECTED); - expect(client.status).toBe(App.StatusEnum.CONNECTED); + client.updateStatus(StatusEnum.CONNECTED); + expect(client.status).toBe(StatusEnum.CONNECTED); }); it('calls protobuf.resetCommands on DISCONNECTED', () => { - client.updateStatus(App.StatusEnum.DISCONNECTED); + client.updateStatus(StatusEnum.DISCONNECTED); expect(client.protobuf.resetCommands).toHaveBeenCalled(); }); 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(); }); }); describe('constructor closures', () => { - it('keepAliveFn calls ping with the callback', () => { - const cb = vi.fn(); - captured.wsOptions!.keepAliveFn(cb); - expect(ping).toHaveBeenCalledWith(cb); + it('keepAliveFn is set to ping function in WebSocketService', () => { + expect(captured.wsOptions!.keepAliveFn).toBeDefined(); + expect(typeof captured.wsOptions!.keepAliveFn).toBe('function'); }); it('onStatusChange routes to response.session.updateStatus and updates own status', () => { - captured.wsOptions!.onStatusChange(App.StatusEnum.CONNECTED, 'Connected'); - expect(mockResponse.session.updateStatus).toHaveBeenCalledWith(App.StatusEnum.CONNECTED, 'Connected'); - expect(client.status).toBe(App.StatusEnum.CONNECTED); + captured.wsOptions!.onStatusChange(StatusEnum.CONNECTED, 'Connected'); + expect(mockResponse.session.updateStatus).toHaveBeenCalledWith(StatusEnum.CONNECTED, 'Connected'); + expect(client.status).toBe(StatusEnum.CONNECTED); + }); + + it('onConnectionFailed routes to response.session.connectionFailed', () => { + captured.wsOptions!.onConnectionFailed(); + expect(mockResponse.session.connectionFailed).toHaveBeenCalled(); }); it('send closure delegates to socket.send', () => { diff --git a/webclient/src/websocket/WebClient.ts b/webclient/src/websocket/WebClient.ts index 310ffae40..ddd197ce6 100644 --- a/webclient/src/websocket/WebClient.ts +++ b/webclient/src/websocket/WebClient.ts @@ -1,52 +1,55 @@ -import { App, Enriched } from '@app/types'; - -import { ProtobufService } from './services/ProtobufService'; -import { WebSocketService } from './services/WebSocketService'; import { ping } from './commands/session'; import { CLIENT_OPTIONS } from './config'; -import { IWebClientResponse, IWebClientRequest } from './interfaces'; +import type { + ConnectTarget, + IWebClientRequest, + IWebClientResponse, +} from './interfaces'; +import { StatusEnum } from './interfaces'; +import { ProtobufService } from './services/ProtobufService'; +import { WebSocketService } from './services/WebSocketService'; export class WebClient { private static _instance: WebClient | null = null; - public static get instance(): WebClient { + static get instance(): WebClient { if (!WebClient._instance) { 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()` before accessing `WebClient.instance`.' ); } return WebClient._instance; } - public socket: WebSocketService; - public protobuf: ProtobufService; - public response: IWebClientResponse; - public request: IWebClientRequest; + protobuf: ProtobufService; + socket: WebSocketService; + status: StatusEnum; - public options: Enriched.WebSocketConnectOptions | null = null; - public status: App.StatusEnum; - - constructor(response: IWebClientResponse, request: IWebClientRequest) { + constructor( + public request: IWebClientRequest, + public response: IWebClientResponse + ) { if (WebClient._instance) { throw new Error('WebClient is a singleton and has already been initialized.'); } - this.response = response; - this.request = request; - this.socket = new WebSocketService({ - keepAliveFn: (cb) => ping(cb), - response, + keepAliveFn: ping, onStatusChange: (status, description) => { this.response.session.updateStatus(status, description); this.updateStatus(status); }, + onConnectionFailed: () => { + this.response.session.connectionFailed(); + }, }); - this.protobuf = new ProtobufService({ - send: (data) => this.socket.send(data), - isOpen: () => this.socket.checkReadyState(WebSocket.OPEN), - }); + this.protobuf = new ProtobufService( + { + send: (data) => this.socket.send(data), + isOpen: () => this.socket.checkReadyState(WebSocket.OPEN), + } + ); this.socket.message$.subscribe((message: MessageEvent) => { this.protobuf.handleMessageEvent(message); @@ -57,15 +60,14 @@ export class WebClient { this.response.session.initialized(); } - public connect(options: Enriched.WebSocketConnectOptions) { + public connect(target: ConnectTarget) { this.response.session.connectionAttempted(); - this.options = options; - this.socket.connect(options); + this.socket.connect(target); } - public testConnect(options: Enriched.WebSocketConnectOptions) { + public testConnect(target: ConnectTarget) { const protocol = window.location.hostname === 'localhost' ? 'ws' : 'wss'; - const { host, port } = options; + const { host, port } = target; const socket = new WebSocket(`${protocol}://${host}:${port}`); socket.binaryType = 'arraybuffer'; @@ -88,10 +90,10 @@ export class WebClient { this.socket.disconnect(); } - public updateStatus(status: App.StatusEnum) { + public updateStatus(status: StatusEnum) { this.status = status; - if (status === App.StatusEnum.DISCONNECTED) { + if (status === StatusEnum.DISCONNECTED) { this.protobuf.resetCommands(); } } diff --git a/webclient/src/websocket/__mocks__/WebClient.ts b/webclient/src/websocket/__mocks__/WebClient.ts new file mode 100644 index 000000000..1219c3466 --- /dev/null +++ b/webclient/src/websocket/__mocks__/WebClient.ts @@ -0,0 +1,185 @@ +/** + * Shared WebClient mock — the single source of truth for all websocket + * layer unit tests. + * + * Vitest resolves this file whenever a spec calls `vi.mock('...WebClient')` + * without providing a factory. Each spec file gets its own module graph + * (isolate: true), so there are no factory-conflict issues. + * + * Usage in spec files: + * + * vi.mock('../../WebClient'); + * import { WebClient } from '../../WebClient'; + * // WebClient.instance.response.game.cardMoved ← vi.fn() + * // WebClient.instance.protobuf.sendGameCommand ← vi.fn() + * + * `useWebClientCleanup()` is NOT required — `instance` is a plain + * property, not a getter that throws. + */ + +// --------------------------------------------------------------------------- +// response.session (ISessionResponse) +// --------------------------------------------------------------------------- +const session = { + initialized: vi.fn(), + connectionAttempted: vi.fn(), + clearStore: vi.fn(), + loginSuccessful: vi.fn(), + loginFailed: vi.fn(), + connectionFailed: vi.fn(), + testConnectionSuccessful: vi.fn(), + testConnectionFailed: vi.fn(), + updateBuddyList: vi.fn(), + addToBuddyList: vi.fn(), + removeFromBuddyList: vi.fn(), + updateIgnoreList: vi.fn(), + addToIgnoreList: vi.fn(), + removeFromIgnoreList: vi.fn(), + updateInfo: vi.fn(), + updateStatus: vi.fn(), + updateUser: vi.fn(), + updateUsers: vi.fn(), + userJoined: vi.fn(), + userLeft: vi.fn(), + serverMessage: vi.fn(), + accountAwaitingActivation: vi.fn(), + accountActivationSuccess: vi.fn(), + accountActivationFailed: vi.fn(), + registrationRequiresEmail: vi.fn(), + registrationSuccess: vi.fn(), + registrationFailed: vi.fn(), + registrationEmailError: vi.fn(), + registrationPasswordError: vi.fn(), + registrationUserNameError: vi.fn(), + resetPasswordChallenge: vi.fn(), + resetPassword: vi.fn(), + resetPasswordSuccess: vi.fn(), + resetPasswordFailed: vi.fn(), + accountPasswordChange: vi.fn(), + accountEditChanged: vi.fn(), + accountImageChanged: vi.fn(), + getUserInfo: vi.fn(), + getGamesOfUser: vi.fn(), + gameJoined: vi.fn(), + notifyUser: vi.fn(), + playerPropertiesChanged: vi.fn(), + serverShutdown: vi.fn(), + userMessage: vi.fn(), + addToList: vi.fn(), + removeFromList: vi.fn(), + deleteServerDeck: vi.fn(), + updateServerDecks: vi.fn(), + uploadServerDeck: vi.fn(), + downloadServerDeck: vi.fn(), + createServerDeckDir: vi.fn(), + deleteServerDeckDir: vi.fn(), + replayList: vi.fn(), + replayAdded: vi.fn(), + replayModifyMatch: vi.fn(), + replayDeleteMatch: vi.fn(), + replayDownloaded: vi.fn(), +}; + +// --------------------------------------------------------------------------- +// response.room (IRoomResponse) +// --------------------------------------------------------------------------- +const room = { + clearStore: vi.fn(), + joinRoom: vi.fn(), + leaveRoom: vi.fn(), + updateRooms: vi.fn(), + updateGames: vi.fn(), + addMessage: vi.fn(), + userJoined: vi.fn(), + userLeft: vi.fn(), + removeMessages: vi.fn(), + gameCreated: vi.fn(), + joinedGame: vi.fn(), +}; + +// --------------------------------------------------------------------------- +// response.game (IGameResponse) +// --------------------------------------------------------------------------- +const game = { + clearStore: vi.fn(), + gameStateChanged: vi.fn(), + playerJoined: vi.fn(), + playerLeft: vi.fn(), + playerPropertiesChanged: vi.fn(), + gameClosed: vi.fn(), + gameHostChanged: vi.fn(), + kicked: vi.fn(), + gameSay: vi.fn(), + cardMoved: vi.fn(), + cardFlipped: vi.fn(), + cardDestroyed: vi.fn(), + cardAttached: vi.fn(), + tokenCreated: vi.fn(), + cardAttrChanged: vi.fn(), + cardCounterChanged: vi.fn(), + arrowCreated: vi.fn(), + arrowDeleted: vi.fn(), + counterCreated: vi.fn(), + counterSet: vi.fn(), + counterDeleted: vi.fn(), + cardsDrawn: vi.fn(), + cardsRevealed: vi.fn(), + zoneShuffled: vi.fn(), + dieRolled: vi.fn(), + activePlayerSet: vi.fn(), + activePhaseSet: vi.fn(), + turnReversed: vi.fn(), + zoneDumped: vi.fn(), + zonePropertiesChanged: vi.fn(), +}; + +// --------------------------------------------------------------------------- +// response.admin (IAdminResponse) +// --------------------------------------------------------------------------- +const admin = { + adjustMod: vi.fn(), + reloadConfig: vi.fn(), + shutdownServer: vi.fn(), + updateServerMessage: vi.fn(), +}; + +// --------------------------------------------------------------------------- +// response.moderator (IModeratorResponse) +// --------------------------------------------------------------------------- +const moderator = { + banFromServer: vi.fn(), + banHistory: vi.fn(), + viewLogs: vi.fn(), + warnHistory: vi.fn(), + warnListOptions: vi.fn(), + warnUser: vi.fn(), + grantReplayAccess: vi.fn(), + forceActivateUser: vi.fn(), + getAdminNotes: vi.fn(), + updateAdminNotes: vi.fn(), +}; + +// --------------------------------------------------------------------------- +// Exported mock — replaces the real WebClient module for all consumers. +// --------------------------------------------------------------------------- +export const WebClient = { + _instance: null as any, + instance: { + connect: vi.fn(), + testConnect: vi.fn(), + disconnect: vi.fn(), + updateStatus: vi.fn(), + status: 0 as number, + config: {}, + protobuf: { + sendSessionCommand: vi.fn(), + sendRoomCommand: vi.fn(), + sendGameCommand: vi.fn(), + sendAdminCommand: vi.fn(), + sendModeratorCommand: vi.fn(), + resetCommands: vi.fn(), + }, + response: { session, room, game, admin, moderator }, + }, +}; + diff --git a/webclient/src/websocket/__mocks__/helpers.ts b/webclient/src/websocket/__mocks__/helpers.ts index ec05d87d2..c76fd9f17 100644 --- a/webclient/src/websocket/__mocks__/helpers.ts +++ b/webclient/src/websocket/__mocks__/helpers.ts @@ -1,8 +1,27 @@ /** * Shared mock factories for websocket layer unit tests. * 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 */ export function makeMockWebSocketInstance() { diff --git a/webclient/src/websocket/__mocks__/sessionCommandMocks.ts b/webclient/src/websocket/__mocks__/sessionCommandMocks.ts index e12d89ccf..d82cbed0f 100644 --- a/webclient/src/websocket/__mocks__/sessionCommandMocks.ts +++ b/webclient/src/websocket/__mocks__/sessionCommandMocks.ts @@ -16,7 +16,7 @@ export function makeWebClientMock() { testConnect: vi.fn(), disconnect: vi.fn(), updateStatus: vi.fn(), - options: {}, + config: {}, status: 0, protobuf: { sendSessionCommand: vi.fn(), diff --git a/webclient/src/websocket/commands/admin/adjustMod.ts b/webclient/src/websocket/commands/admin/adjustMod.ts index 1641f64ce..7d9562506 100644 --- a/webclient/src/websocket/commands/admin/adjustMod.ts +++ b/webclient/src/websocket/commands/admin/adjustMod.ts @@ -1,10 +1,10 @@ import { create } from '@bufbuild/protobuf'; -import { Data } from '@app/types'; +import { Command_AdjustMod_ext, Command_AdjustModSchema } from '@app/generated'; import { WebClient } from '../../WebClient'; export function adjustMod(userName: string, shouldBeMod?: boolean, shouldBeJudge?: boolean): void { WebClient.instance.protobuf.sendAdminCommand( - Data.Command_AdjustMod_ext, - create(Data.Command_AdjustModSchema, { userName, shouldBeMod, shouldBeJudge }), + Command_AdjustMod_ext, + create(Command_AdjustModSchema, { userName, shouldBeMod, shouldBeJudge }), { onSuccess: () => { WebClient.instance.response.admin.adjustMod(userName, shouldBeMod, shouldBeJudge); diff --git a/webclient/src/websocket/commands/admin/adminCommands.spec.ts b/webclient/src/websocket/commands/admin/adminCommands.spec.ts index 88a055f1a..51764a627 100644 --- a/webclient/src/websocket/commands/admin/adminCommands.spec.ts +++ b/webclient/src/websocket/commands/admin/adminCommands.spec.ts @@ -1,18 +1,4 @@ -vi.mock('../../WebClient', () => ({ - WebClient: { - instance: { - protobuf: { sendAdminCommand: vi.fn() }, - response: { - admin: { - adjustMod: vi.fn(), - reloadConfig: vi.fn(), - shutdownServer: vi.fn(), - updateServerMessage: vi.fn(), - }, - }, - }, - }, -})); +vi.mock('../../WebClient'); import { makeCallbackHelpers } from '../../__mocks__/callbackHelpers'; import { WebClient } from '../../WebClient'; @@ -20,6 +6,12 @@ import { adjustMod } from './adjustMod'; import { reloadConfig } from './reloadConfig'; import { shutdownServer } from './shutdownServer'; import { updateServerMessage } from './updateServerMessage'; +import { + Command_AdjustMod_ext, + Command_ReloadConfig_ext, + Command_ShutdownServer_ext, + Command_UpdateServerMessage_ext, +} from '@app/generated'; import { Mock } from 'vitest'; @@ -33,9 +25,13 @@ const { invokeOnSuccess } = makeCallbackHelpers( // ---------------------------------------------------------------- describe('adjustMod', () => { - it('calls sendAdminCommand with Command_AdjustMod', () => { + it('calls sendAdminCommand with Command_AdjustMod extension and fields', () => { adjustMod('alice', true, false); - expect(WebClient.instance.protobuf.sendAdminCommand).toHaveBeenCalledWith(expect.any(Object), expect.any(Object), expect.any(Object)); + expect(WebClient.instance.protobuf.sendAdminCommand).toHaveBeenCalledWith( + Command_AdjustMod_ext, + expect.objectContaining({ userName: 'alice', shouldBeMod: true, shouldBeJudge: false }), + expect.any(Object) + ); }); it('onSuccess calls response.admin.adjustMod', () => { @@ -50,9 +46,13 @@ describe('adjustMod', () => { // ---------------------------------------------------------------- describe('reloadConfig', () => { - it('calls sendAdminCommand with Command_ReloadConfig', () => { + it('calls sendAdminCommand with Command_ReloadConfig extension', () => { reloadConfig(); - expect(WebClient.instance.protobuf.sendAdminCommand).toHaveBeenCalledWith(expect.any(Object), expect.any(Object), expect.any(Object)); + expect(WebClient.instance.protobuf.sendAdminCommand).toHaveBeenCalledWith( + Command_ReloadConfig_ext, + expect.any(Object), + expect.any(Object) + ); }); it('onSuccess calls response.admin.reloadConfig', () => { @@ -67,9 +67,13 @@ describe('reloadConfig', () => { // ---------------------------------------------------------------- describe('shutdownServer', () => { - it('calls sendAdminCommand with Command_ShutdownServer', () => { + it('calls sendAdminCommand with Command_ShutdownServer extension and fields', () => { shutdownServer('maintenance', 10); - expect(WebClient.instance.protobuf.sendAdminCommand).toHaveBeenCalledWith(expect.any(Object), expect.any(Object), expect.any(Object)); + expect(WebClient.instance.protobuf.sendAdminCommand).toHaveBeenCalledWith( + Command_ShutdownServer_ext, + expect.objectContaining({ reason: 'maintenance', minutes: 10 }), + expect.any(Object) + ); }); it('onSuccess calls response.admin.shutdownServer', () => { @@ -84,9 +88,13 @@ describe('shutdownServer', () => { // ---------------------------------------------------------------- describe('updateServerMessage', () => { - it('calls sendAdminCommand with Command_UpdateServerMessage', () => { + it('calls sendAdminCommand with Command_UpdateServerMessage extension', () => { updateServerMessage(); - expect(WebClient.instance.protobuf.sendAdminCommand).toHaveBeenCalledWith(expect.any(Object), expect.any(Object), expect.any(Object)); + expect(WebClient.instance.protobuf.sendAdminCommand).toHaveBeenCalledWith( + Command_UpdateServerMessage_ext, + expect.any(Object), + expect.any(Object) + ); }); it('onSuccess calls response.admin.updateServerMessage', () => { diff --git a/webclient/src/websocket/commands/admin/reloadConfig.ts b/webclient/src/websocket/commands/admin/reloadConfig.ts index 25ad85b1f..0856db673 100644 --- a/webclient/src/websocket/commands/admin/reloadConfig.ts +++ b/webclient/src/websocket/commands/admin/reloadConfig.ts @@ -1,8 +1,8 @@ import { create } from '@bufbuild/protobuf'; -import { Data } from '@app/types'; +import { Command_ReloadConfig_ext, Command_ReloadConfigSchema } from '@app/generated'; import { WebClient } from '../../WebClient'; 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: () => { WebClient.instance.response.admin.reloadConfig(); }, diff --git a/webclient/src/websocket/commands/admin/shutdownServer.ts b/webclient/src/websocket/commands/admin/shutdownServer.ts index 69a2e56d7..a341ffa81 100644 --- a/webclient/src/websocket/commands/admin/shutdownServer.ts +++ b/webclient/src/websocket/commands/admin/shutdownServer.ts @@ -1,10 +1,10 @@ import { create } from '@bufbuild/protobuf'; -import { Data } from '@app/types'; +import { Command_ShutdownServer_ext, Command_ShutdownServerSchema } from '@app/generated'; import { WebClient } from '../../WebClient'; export function shutdownServer(reason: string, minutes: number): void { WebClient.instance.protobuf.sendAdminCommand( - Data.Command_ShutdownServer_ext, - create(Data.Command_ShutdownServerSchema, { reason, minutes }), + Command_ShutdownServer_ext, + create(Command_ShutdownServerSchema, { reason, minutes }), { onSuccess: () => { WebClient.instance.response.admin.shutdownServer(); diff --git a/webclient/src/websocket/commands/admin/updateServerMessage.ts b/webclient/src/websocket/commands/admin/updateServerMessage.ts index b56b351b0..7c99ddd5b 100644 --- a/webclient/src/websocket/commands/admin/updateServerMessage.ts +++ b/webclient/src/websocket/commands/admin/updateServerMessage.ts @@ -1,8 +1,8 @@ import { create } from '@bufbuild/protobuf'; -import { Data } from '@app/types'; +import { Command_UpdateServerMessage_ext, Command_UpdateServerMessageSchema } from '@app/generated'; import { WebClient } from '../../WebClient'; 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: () => { WebClient.instance.response.admin.updateServerMessage(); }, diff --git a/webclient/src/websocket/commands/game/attachCard.ts b/webclient/src/websocket/commands/game/attachCard.ts index ac9128436..5864a331a 100644 --- a/webclient/src/websocket/commands/game/attachCard.ts +++ b/webclient/src/websocket/commands/game/attachCard.ts @@ -1,8 +1,7 @@ import { create } from '@bufbuild/protobuf'; +import { Command_AttachCard_ext, Command_AttachCardSchema, type AttachCardParams } from '@app/generated'; import { WebClient } from '../../WebClient'; -import { Data } from '@app/types'; - -export function attachCard(gameId: number, params: Data.AttachCardParams): void { - WebClient.instance.protobuf.sendGameCommand(gameId, Data.Command_AttachCard_ext, create(Data.Command_AttachCardSchema, params)); +export function attachCard(gameId: number, params: AttachCardParams): void { + WebClient.instance.protobuf.sendGameCommand(gameId, Command_AttachCard_ext, create(Command_AttachCardSchema, params)); } diff --git a/webclient/src/websocket/commands/game/changeZoneProperties.ts b/webclient/src/websocket/commands/game/changeZoneProperties.ts index ad10f0978..e186bba91 100644 --- a/webclient/src/websocket/commands/game/changeZoneProperties.ts +++ b/webclient/src/websocket/commands/game/changeZoneProperties.ts @@ -1,12 +1,11 @@ import { create } from '@bufbuild/protobuf'; +import { Command_ChangeZoneProperties_ext, Command_ChangeZonePropertiesSchema, type ChangeZonePropertiesParams } from '@app/generated'; import { WebClient } from '../../WebClient'; -import { Data } from '@app/types'; - -export function changeZoneProperties(gameId: number, params: Data.ChangeZonePropertiesParams): void { +export function changeZoneProperties(gameId: number, params: ChangeZonePropertiesParams): void { WebClient.instance.protobuf.sendGameCommand( gameId, - Data.Command_ChangeZoneProperties_ext, - create(Data.Command_ChangeZonePropertiesSchema, params) + Command_ChangeZoneProperties_ext, + create(Command_ChangeZonePropertiesSchema, params) ); } diff --git a/webclient/src/websocket/commands/game/concede.ts b/webclient/src/websocket/commands/game/concede.ts index fb9634a34..9007e1223 100644 --- a/webclient/src/websocket/commands/game/concede.ts +++ b/webclient/src/websocket/commands/game/concede.ts @@ -1,7 +1,7 @@ import { create } from '@bufbuild/protobuf'; +import { Command_Concede_ext, Command_ConcedeSchema } from '@app/generated'; import { WebClient } from '../../WebClient'; -import { Data } from '@app/types'; 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)); } diff --git a/webclient/src/websocket/commands/game/createArrow.ts b/webclient/src/websocket/commands/game/createArrow.ts index 6b727d631..87aaf094c 100644 --- a/webclient/src/websocket/commands/game/createArrow.ts +++ b/webclient/src/websocket/commands/game/createArrow.ts @@ -1,8 +1,7 @@ import { create } from '@bufbuild/protobuf'; +import { Command_CreateArrow_ext, Command_CreateArrowSchema, type CreateArrowParams } from '@app/generated'; import { WebClient } from '../../WebClient'; -import { Data } from '@app/types'; - -export function createArrow(gameId: number, params: Data.CreateArrowParams): void { - WebClient.instance.protobuf.sendGameCommand(gameId, Data.Command_CreateArrow_ext, create(Data.Command_CreateArrowSchema, params)); +export function createArrow(gameId: number, params: CreateArrowParams): void { + WebClient.instance.protobuf.sendGameCommand(gameId, Command_CreateArrow_ext, create(Command_CreateArrowSchema, params)); } diff --git a/webclient/src/websocket/commands/game/createCounter.ts b/webclient/src/websocket/commands/game/createCounter.ts index 28f897944..886c63b3b 100644 --- a/webclient/src/websocket/commands/game/createCounter.ts +++ b/webclient/src/websocket/commands/game/createCounter.ts @@ -1,8 +1,7 @@ import { create } from '@bufbuild/protobuf'; +import { Command_CreateCounter_ext, Command_CreateCounterSchema, type CreateCounterParams } from '@app/generated'; import { WebClient } from '../../WebClient'; -import { Data } from '@app/types'; - -export function createCounter(gameId: number, params: Data.CreateCounterParams): void { - WebClient.instance.protobuf.sendGameCommand(gameId, Data.Command_CreateCounter_ext, create(Data.Command_CreateCounterSchema, params)); +export function createCounter(gameId: number, params: CreateCounterParams): void { + WebClient.instance.protobuf.sendGameCommand(gameId, Command_CreateCounter_ext, create(Command_CreateCounterSchema, params)); } diff --git a/webclient/src/websocket/commands/game/createToken.ts b/webclient/src/websocket/commands/game/createToken.ts index 2e8902981..b4c837417 100644 --- a/webclient/src/websocket/commands/game/createToken.ts +++ b/webclient/src/websocket/commands/game/createToken.ts @@ -1,8 +1,7 @@ import { create } from '@bufbuild/protobuf'; +import { Command_CreateToken_ext, Command_CreateTokenSchema, type CreateTokenParams } from '@app/generated'; import { WebClient } from '../../WebClient'; -import { Data } from '@app/types'; - -export function createToken(gameId: number, params: Data.CreateTokenParams): void { - WebClient.instance.protobuf.sendGameCommand(gameId, Data.Command_CreateToken_ext, create(Data.Command_CreateTokenSchema, params)); +export function createToken(gameId: number, params: CreateTokenParams): void { + WebClient.instance.protobuf.sendGameCommand(gameId, Command_CreateToken_ext, create(Command_CreateTokenSchema, params)); } diff --git a/webclient/src/websocket/commands/game/deckSelect.ts b/webclient/src/websocket/commands/game/deckSelect.ts index ea4c7f455..d2f28a7c0 100644 --- a/webclient/src/websocket/commands/game/deckSelect.ts +++ b/webclient/src/websocket/commands/game/deckSelect.ts @@ -1,8 +1,7 @@ import { create } from '@bufbuild/protobuf'; +import { Command_DeckSelect_ext, Command_DeckSelectSchema, type DeckSelectParams } from '@app/generated'; import { WebClient } from '../../WebClient'; -import { Data } from '@app/types'; - -export function deckSelect(gameId: number, params: Data.DeckSelectParams): void { - WebClient.instance.protobuf.sendGameCommand(gameId, Data.Command_DeckSelect_ext, create(Data.Command_DeckSelectSchema, params)); +export function deckSelect(gameId: number, params: DeckSelectParams): void { + WebClient.instance.protobuf.sendGameCommand(gameId, Command_DeckSelect_ext, create(Command_DeckSelectSchema, params)); } diff --git a/webclient/src/websocket/commands/game/delCounter.ts b/webclient/src/websocket/commands/game/delCounter.ts index fec44bd68..6b215d123 100644 --- a/webclient/src/websocket/commands/game/delCounter.ts +++ b/webclient/src/websocket/commands/game/delCounter.ts @@ -1,8 +1,7 @@ import { create } from '@bufbuild/protobuf'; +import { Command_DelCounter_ext, Command_DelCounterSchema, type DelCounterParams } from '@app/generated'; import { WebClient } from '../../WebClient'; -import { Data } from '@app/types'; - -export function delCounter(gameId: number, params: Data.DelCounterParams): void { - WebClient.instance.protobuf.sendGameCommand(gameId, Data.Command_DelCounter_ext, create(Data.Command_DelCounterSchema, params)); +export function delCounter(gameId: number, params: DelCounterParams): void { + WebClient.instance.protobuf.sendGameCommand(gameId, Command_DelCounter_ext, create(Command_DelCounterSchema, params)); } diff --git a/webclient/src/websocket/commands/game/deleteArrow.ts b/webclient/src/websocket/commands/game/deleteArrow.ts index 8e8dc9be3..8391277f7 100644 --- a/webclient/src/websocket/commands/game/deleteArrow.ts +++ b/webclient/src/websocket/commands/game/deleteArrow.ts @@ -1,8 +1,7 @@ import { create } from '@bufbuild/protobuf'; +import { Command_DeleteArrow_ext, Command_DeleteArrowSchema, type DeleteArrowParams } from '@app/generated'; import { WebClient } from '../../WebClient'; -import { Data } from '@app/types'; - -export function deleteArrow(gameId: number, params: Data.DeleteArrowParams): void { - WebClient.instance.protobuf.sendGameCommand(gameId, Data.Command_DeleteArrow_ext, create(Data.Command_DeleteArrowSchema, params)); +export function deleteArrow(gameId: number, params: DeleteArrowParams): void { + WebClient.instance.protobuf.sendGameCommand(gameId, Command_DeleteArrow_ext, create(Command_DeleteArrowSchema, params)); } diff --git a/webclient/src/websocket/commands/game/drawCards.ts b/webclient/src/websocket/commands/game/drawCards.ts index 04309bf63..5c7ed8c0a 100644 --- a/webclient/src/websocket/commands/game/drawCards.ts +++ b/webclient/src/websocket/commands/game/drawCards.ts @@ -1,8 +1,7 @@ import { create } from '@bufbuild/protobuf'; +import { Command_DrawCards_ext, Command_DrawCardsSchema, type DrawCardsParams } from '@app/generated'; import { WebClient } from '../../WebClient'; -import { Data } from '@app/types'; - -export function drawCards(gameId: number, params: Data.DrawCardsParams): void { - WebClient.instance.protobuf.sendGameCommand(gameId, Data.Command_DrawCards_ext, create(Data.Command_DrawCardsSchema, params)); +export function drawCards(gameId: number, params: DrawCardsParams): void { + WebClient.instance.protobuf.sendGameCommand(gameId, Command_DrawCards_ext, create(Command_DrawCardsSchema, params)); } diff --git a/webclient/src/websocket/commands/game/dumpZone.ts b/webclient/src/websocket/commands/game/dumpZone.ts index c5663a732..62d792d2e 100644 --- a/webclient/src/websocket/commands/game/dumpZone.ts +++ b/webclient/src/websocket/commands/game/dumpZone.ts @@ -1,8 +1,7 @@ import { create } from '@bufbuild/protobuf'; +import { Command_DumpZone_ext, Command_DumpZoneSchema, type DumpZoneParams } from '@app/generated'; import { WebClient } from '../../WebClient'; -import { Data } from '@app/types'; - -export function dumpZone(gameId: number, params: Data.DumpZoneParams): void { - WebClient.instance.protobuf.sendGameCommand(gameId, Data.Command_DumpZone_ext, create(Data.Command_DumpZoneSchema, params)); +export function dumpZone(gameId: number, params: DumpZoneParams): void { + WebClient.instance.protobuf.sendGameCommand(gameId, Command_DumpZone_ext, create(Command_DumpZoneSchema, params)); } diff --git a/webclient/src/websocket/commands/game/flipCard.ts b/webclient/src/websocket/commands/game/flipCard.ts index ee16a938a..b4b894962 100644 --- a/webclient/src/websocket/commands/game/flipCard.ts +++ b/webclient/src/websocket/commands/game/flipCard.ts @@ -1,8 +1,7 @@ import { create } from '@bufbuild/protobuf'; +import { Command_FlipCard_ext, Command_FlipCardSchema, type FlipCardParams } from '@app/generated'; import { WebClient } from '../../WebClient'; -import { Data } from '@app/types'; - -export function flipCard(gameId: number, params: Data.FlipCardParams): void { - WebClient.instance.protobuf.sendGameCommand(gameId, Data.Command_FlipCard_ext, create(Data.Command_FlipCardSchema, params)); +export function flipCard(gameId: number, params: FlipCardParams): void { + WebClient.instance.protobuf.sendGameCommand(gameId, Command_FlipCard_ext, create(Command_FlipCardSchema, params)); } diff --git a/webclient/src/websocket/commands/game/gameCommands.spec.ts b/webclient/src/websocket/commands/game/gameCommands.spec.ts index dee19f57c..8706a9e3d 100644 --- a/webclient/src/websocket/commands/game/gameCommands.spec.ts +++ b/webclient/src/websocket/commands/game/gameCommands.spec.ts @@ -1,15 +1,45 @@ -vi.mock('../../WebClient', () => ({ - WebClient: { - instance: { - protobuf: { sendGameCommand: vi.fn() }, - response: { game: {} }, - }, - }, -})); +vi.mock('../../WebClient'); import { WebClient } from '../../WebClient'; 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 { changeZoneProperties } from './changeZoneProperties'; @@ -52,122 +82,122 @@ describe('Game commands — delegate to WebClient.instance.protobuf.sendGameComm it('attachCard sends Command_AttachCard', () => { attachCard(gameId, { cardId: 10, startZone: 'hand' }); 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', () => { changeZoneProperties(gameId, { zoneName: 'side' }); 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', () => { 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', () => { createArrow(gameId, { startPlayerId: 1, startZone: 'hand' }); 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', () => { createCounter(gameId, { counterName: 'life' }); 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', () => { createToken(gameId, { cardName: 'Goblin', zone: 'play' }); 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', () => { deckSelect(gameId, { deckId: 5 }); 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', () => { delCounter(gameId, { counterId: 3 }); 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', () => { deleteArrow(gameId, { arrowId: 2 }); 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', () => { drawCards(gameId, { number: 3 }); 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', () => { dumpZone(gameId, { playerId: 2, zoneName: 'library' }); 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', () => { flipCard(gameId, { cardId: 7, faceDown: false }); 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', () => { gameSay(gameId, { message: 'hello' }); 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', () => { incCardCounter(gameId, { cardId: 5, counterId: 1 }); 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', () => { incCounter(gameId, { counterId: 1, delta: 5 }); 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', () => { kickFromGame(gameId, { playerId: 2 }); 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', () => { 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', () => { moveCard(gameId, { startZone: 'hand', targetZone: 'graveyard' }); expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith( - gameId, Data.Command_MoveCard_ext, + gameId, Command_MoveCard_ext, expect.objectContaining({ startZone: 'hand', targetZone: 'graveyard' }) ); }); @@ -175,45 +205,45 @@ describe('Game commands — delegate to WebClient.instance.protobuf.sendGameComm it('mulligan sends Command_Mulligan', () => { mulligan(gameId, { number: 7 }); 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', () => { 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', () => { readyStart(gameId, { ready: true }); 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', () => { revealCards(gameId, { zoneName: 'hand', cardId: [1, 2] }); 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', () => { 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', () => { setActivePhase(gameId, { phase: 2 }); 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', () => { setCardAttr(gameId, { zone: 'play', cardId: 5, attrValue: '2' }); expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith( - gameId, Data.Command_SetCardAttr_ext, + gameId, Command_SetCardAttr_ext, expect.objectContaining({ zone: 'play', cardId: 5, attrValue: '2' }) ); }); @@ -221,63 +251,63 @@ describe('Game commands — delegate to WebClient.instance.protobuf.sendGameComm it('setCardCounter sends Command_SetCardCounter', () => { setCardCounter(gameId, { cardId: 5, counterId: 1 }); 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', () => { setCounter(gameId, { counterId: 1, value: 10 }); 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', () => { setSideboardLock(gameId, { locked: true }); 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', () => { setSideboardPlan(gameId, { moveList: [] }); 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', () => { shuffle(gameId, { zoneName: 'hand' }); 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', () => { 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', () => { 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', () => { rollDie(gameId, { sides: 6, count: 2 }); 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', () => { const targetId = 3; - const innerCmd = create(Data.GameCommandSchema); - setExtension(innerCmd, Data.Command_DrawCards_ext, create(Data.Command_DrawCardsSchema, { number: 2 })); + const innerCmd = create(GameCommandSchema); + setExtension(innerCmd, Command_DrawCards_ext, create(Command_DrawCardsSchema, { number: 2 })); judge(gameId, targetId, innerCmd); expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith( gameId, - Data.Command_Judge_ext, + Command_Judge_ext, expect.objectContaining({ targetId: 3, gameCommand: expect.any(Array) }) ); }); diff --git a/webclient/src/websocket/commands/game/gameSay.ts b/webclient/src/websocket/commands/game/gameSay.ts index 371a3e19d..7ae7263e9 100644 --- a/webclient/src/websocket/commands/game/gameSay.ts +++ b/webclient/src/websocket/commands/game/gameSay.ts @@ -1,8 +1,7 @@ import { create } from '@bufbuild/protobuf'; +import { Command_GameSay_ext, Command_GameSaySchema, type GameSayParams } from '@app/generated'; import { WebClient } from '../../WebClient'; -import { Data } from '@app/types'; - -export function gameSay(gameId: number, params: Data.GameSayParams): void { - WebClient.instance.protobuf.sendGameCommand(gameId, Data.Command_GameSay_ext, create(Data.Command_GameSaySchema, params)); +export function gameSay(gameId: number, params: GameSayParams): void { + WebClient.instance.protobuf.sendGameCommand(gameId, Command_GameSay_ext, create(Command_GameSaySchema, params)); } diff --git a/webclient/src/websocket/commands/game/incCardCounter.ts b/webclient/src/websocket/commands/game/incCardCounter.ts index 7374c8ab9..54349d920 100644 --- a/webclient/src/websocket/commands/game/incCardCounter.ts +++ b/webclient/src/websocket/commands/game/incCardCounter.ts @@ -1,8 +1,7 @@ import { create } from '@bufbuild/protobuf'; +import { Command_IncCardCounter_ext, Command_IncCardCounterSchema, type IncCardCounterParams } from '@app/generated'; import { WebClient } from '../../WebClient'; -import { Data } from '@app/types'; - -export function incCardCounter(gameId: number, params: Data.IncCardCounterParams): void { - WebClient.instance.protobuf.sendGameCommand(gameId, Data.Command_IncCardCounter_ext, create(Data.Command_IncCardCounterSchema, params)); +export function incCardCounter(gameId: number, params: IncCardCounterParams): void { + WebClient.instance.protobuf.sendGameCommand(gameId, Command_IncCardCounter_ext, create(Command_IncCardCounterSchema, params)); } diff --git a/webclient/src/websocket/commands/game/incCounter.ts b/webclient/src/websocket/commands/game/incCounter.ts index 038dbfb42..4ca0b8558 100644 --- a/webclient/src/websocket/commands/game/incCounter.ts +++ b/webclient/src/websocket/commands/game/incCounter.ts @@ -1,8 +1,7 @@ import { create } from '@bufbuild/protobuf'; +import { Command_IncCounter_ext, Command_IncCounterSchema, type IncCounterParams } from '@app/generated'; import { WebClient } from '../../WebClient'; -import { Data } from '@app/types'; - -export function incCounter(gameId: number, params: Data.IncCounterParams): void { - WebClient.instance.protobuf.sendGameCommand(gameId, Data.Command_IncCounter_ext, create(Data.Command_IncCounterSchema, params)); +export function incCounter(gameId: number, params: IncCounterParams): void { + WebClient.instance.protobuf.sendGameCommand(gameId, Command_IncCounter_ext, create(Command_IncCounterSchema, params)); } diff --git a/webclient/src/websocket/commands/game/judge.ts b/webclient/src/websocket/commands/game/judge.ts index 274945428..c61a1092d 100644 --- a/webclient/src/websocket/commands/game/judge.ts +++ b/webclient/src/websocket/commands/game/judge.ts @@ -1,11 +1,10 @@ import { create } from '@bufbuild/protobuf'; +import { Command_Judge_ext, Command_JudgeSchema, type GameCommand } from '@app/generated'; import { WebClient } from '../../WebClient'; -import { Data } from '@app/types'; -export function judge(gameId: number, targetId: number, innerGameCommand: Data.GameCommand): void { - WebClient.instance.protobuf.sendGameCommand(gameId, Data.Command_Judge_ext, create(Data.Command_JudgeSchema, { +export function judge(gameId: number, targetId: number, innerGameCommand: GameCommand): void { + WebClient.instance.protobuf.sendGameCommand(gameId, Command_Judge_ext, create(Command_JudgeSchema, { targetId, gameCommand: [innerGameCommand], })); } - diff --git a/webclient/src/websocket/commands/game/kickFromGame.ts b/webclient/src/websocket/commands/game/kickFromGame.ts index 3f55e9078..c7b724b88 100644 --- a/webclient/src/websocket/commands/game/kickFromGame.ts +++ b/webclient/src/websocket/commands/game/kickFromGame.ts @@ -1,8 +1,7 @@ import { create } from '@bufbuild/protobuf'; +import { Command_KickFromGame_ext, Command_KickFromGameSchema, type KickFromGameParams } from '@app/generated'; import { WebClient } from '../../WebClient'; -import { Data } from '@app/types'; - -export function kickFromGame(gameId: number, params: Data.KickFromGameParams): void { - WebClient.instance.protobuf.sendGameCommand(gameId, Data.Command_KickFromGame_ext, create(Data.Command_KickFromGameSchema, params)); +export function kickFromGame(gameId: number, params: KickFromGameParams): void { + WebClient.instance.protobuf.sendGameCommand(gameId, Command_KickFromGame_ext, create(Command_KickFromGameSchema, params)); } diff --git a/webclient/src/websocket/commands/game/leaveGame.ts b/webclient/src/websocket/commands/game/leaveGame.ts index ad8b40634..05d160059 100644 --- a/webclient/src/websocket/commands/game/leaveGame.ts +++ b/webclient/src/websocket/commands/game/leaveGame.ts @@ -1,7 +1,7 @@ import { create } from '@bufbuild/protobuf'; +import { Command_LeaveGame_ext, Command_LeaveGameSchema } from '@app/generated'; import { WebClient } from '../../WebClient'; -import { Data } from '@app/types'; 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)); } diff --git a/webclient/src/websocket/commands/game/moveCard.ts b/webclient/src/websocket/commands/game/moveCard.ts index 16390d3a5..9b5dc52b5 100644 --- a/webclient/src/websocket/commands/game/moveCard.ts +++ b/webclient/src/websocket/commands/game/moveCard.ts @@ -1,8 +1,7 @@ import { create } from '@bufbuild/protobuf'; +import { Command_MoveCard_ext, Command_MoveCardSchema, type MoveCardParams } from '@app/generated'; import { WebClient } from '../../WebClient'; -import { Data } from '@app/types'; - -export function moveCard(gameId: number, params: Data.MoveCardParams): void { - WebClient.instance.protobuf.sendGameCommand(gameId, Data.Command_MoveCard_ext, create(Data.Command_MoveCardSchema, params)); +export function moveCard(gameId: number, params: MoveCardParams): void { + WebClient.instance.protobuf.sendGameCommand(gameId, Command_MoveCard_ext, create(Command_MoveCardSchema, params)); } diff --git a/webclient/src/websocket/commands/game/mulligan.ts b/webclient/src/websocket/commands/game/mulligan.ts index 5d690b754..b6ac92cb9 100644 --- a/webclient/src/websocket/commands/game/mulligan.ts +++ b/webclient/src/websocket/commands/game/mulligan.ts @@ -1,8 +1,7 @@ import { create } from '@bufbuild/protobuf'; +import { Command_Mulligan_ext, Command_MulliganSchema, type MulliganParams } from '@app/generated'; import { WebClient } from '../../WebClient'; -import { Data } from '@app/types'; - -export function mulligan(gameId: number, params: Data.MulliganParams): void { - WebClient.instance.protobuf.sendGameCommand(gameId, Data.Command_Mulligan_ext, create(Data.Command_MulliganSchema, params)); +export function mulligan(gameId: number, params: MulliganParams): void { + WebClient.instance.protobuf.sendGameCommand(gameId, Command_Mulligan_ext, create(Command_MulliganSchema, params)); } diff --git a/webclient/src/websocket/commands/game/nextTurn.ts b/webclient/src/websocket/commands/game/nextTurn.ts index 08bf084d8..4633e9233 100644 --- a/webclient/src/websocket/commands/game/nextTurn.ts +++ b/webclient/src/websocket/commands/game/nextTurn.ts @@ -1,7 +1,7 @@ import { create } from '@bufbuild/protobuf'; +import { Command_NextTurn_ext, Command_NextTurnSchema } from '@app/generated'; import { WebClient } from '../../WebClient'; -import { Data } from '@app/types'; 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)); } diff --git a/webclient/src/websocket/commands/game/readyStart.ts b/webclient/src/websocket/commands/game/readyStart.ts index f6f32c401..29c65c2a8 100644 --- a/webclient/src/websocket/commands/game/readyStart.ts +++ b/webclient/src/websocket/commands/game/readyStart.ts @@ -1,8 +1,7 @@ import { create } from '@bufbuild/protobuf'; +import { Command_ReadyStart_ext, Command_ReadyStartSchema, type ReadyStartParams } from '@app/generated'; import { WebClient } from '../../WebClient'; -import { Data } from '@app/types'; - -export function readyStart(gameId: number, params: Data.ReadyStartParams): void { - WebClient.instance.protobuf.sendGameCommand(gameId, Data.Command_ReadyStart_ext, create(Data.Command_ReadyStartSchema, params)); +export function readyStart(gameId: number, params: ReadyStartParams): void { + WebClient.instance.protobuf.sendGameCommand(gameId, Command_ReadyStart_ext, create(Command_ReadyStartSchema, params)); } diff --git a/webclient/src/websocket/commands/game/revealCards.ts b/webclient/src/websocket/commands/game/revealCards.ts index 774074378..e4a1cf715 100644 --- a/webclient/src/websocket/commands/game/revealCards.ts +++ b/webclient/src/websocket/commands/game/revealCards.ts @@ -1,8 +1,7 @@ import { create } from '@bufbuild/protobuf'; +import { Command_RevealCards_ext, Command_RevealCardsSchema, type RevealCardsParams } from '@app/generated'; import { WebClient } from '../../WebClient'; -import { Data } from '@app/types'; - -export function revealCards(gameId: number, params: Data.RevealCardsParams): void { - WebClient.instance.protobuf.sendGameCommand(gameId, Data.Command_RevealCards_ext, create(Data.Command_RevealCardsSchema, params)); +export function revealCards(gameId: number, params: RevealCardsParams): void { + WebClient.instance.protobuf.sendGameCommand(gameId, Command_RevealCards_ext, create(Command_RevealCardsSchema, params)); } diff --git a/webclient/src/websocket/commands/game/reverseTurn.ts b/webclient/src/websocket/commands/game/reverseTurn.ts index 5b03e8e53..7ce90a669 100644 --- a/webclient/src/websocket/commands/game/reverseTurn.ts +++ b/webclient/src/websocket/commands/game/reverseTurn.ts @@ -1,7 +1,7 @@ import { create } from '@bufbuild/protobuf'; +import { Command_ReverseTurn_ext, Command_ReverseTurnSchema } from '@app/generated'; import { WebClient } from '../../WebClient'; -import { Data } from '@app/types'; 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)); } diff --git a/webclient/src/websocket/commands/game/rollDie.ts b/webclient/src/websocket/commands/game/rollDie.ts index c568e2de4..e76bc2445 100644 --- a/webclient/src/websocket/commands/game/rollDie.ts +++ b/webclient/src/websocket/commands/game/rollDie.ts @@ -1,8 +1,7 @@ import { create } from '@bufbuild/protobuf'; +import { Command_RollDie_ext, Command_RollDieSchema, type RollDieParams } from '@app/generated'; import { WebClient } from '../../WebClient'; -import { Data } from '@app/types'; - -export function rollDie(gameId: number, params: Data.RollDieParams): void { - WebClient.instance.protobuf.sendGameCommand(gameId, Data.Command_RollDie_ext, create(Data.Command_RollDieSchema, params)); +export function rollDie(gameId: number, params: RollDieParams): void { + WebClient.instance.protobuf.sendGameCommand(gameId, Command_RollDie_ext, create(Command_RollDieSchema, params)); } diff --git a/webclient/src/websocket/commands/game/setActivePhase.ts b/webclient/src/websocket/commands/game/setActivePhase.ts index 8987ff9d2..c56d42d37 100644 --- a/webclient/src/websocket/commands/game/setActivePhase.ts +++ b/webclient/src/websocket/commands/game/setActivePhase.ts @@ -1,8 +1,7 @@ import { create } from '@bufbuild/protobuf'; +import { Command_SetActivePhase_ext, Command_SetActivePhaseSchema, type SetActivePhaseParams } from '@app/generated'; import { WebClient } from '../../WebClient'; -import { Data } from '@app/types'; - -export function setActivePhase(gameId: number, params: Data.SetActivePhaseParams): void { - WebClient.instance.protobuf.sendGameCommand(gameId, Data.Command_SetActivePhase_ext, create(Data.Command_SetActivePhaseSchema, params)); +export function setActivePhase(gameId: number, params: SetActivePhaseParams): void { + WebClient.instance.protobuf.sendGameCommand(gameId, Command_SetActivePhase_ext, create(Command_SetActivePhaseSchema, params)); } diff --git a/webclient/src/websocket/commands/game/setCardAttr.ts b/webclient/src/websocket/commands/game/setCardAttr.ts index 4808fa4e1..3f62f82a6 100644 --- a/webclient/src/websocket/commands/game/setCardAttr.ts +++ b/webclient/src/websocket/commands/game/setCardAttr.ts @@ -1,8 +1,7 @@ import { create } from '@bufbuild/protobuf'; +import { Command_SetCardAttr_ext, Command_SetCardAttrSchema, type SetCardAttrParams } from '@app/generated'; import { WebClient } from '../../WebClient'; -import { Data } from '@app/types'; - -export function setCardAttr(gameId: number, params: Data.SetCardAttrParams): void { - WebClient.instance.protobuf.sendGameCommand(gameId, Data.Command_SetCardAttr_ext, create(Data.Command_SetCardAttrSchema, params)); +export function setCardAttr(gameId: number, params: SetCardAttrParams): void { + WebClient.instance.protobuf.sendGameCommand(gameId, Command_SetCardAttr_ext, create(Command_SetCardAttrSchema, params)); } diff --git a/webclient/src/websocket/commands/game/setCardCounter.ts b/webclient/src/websocket/commands/game/setCardCounter.ts index 47ebc6f60..e70631ed3 100644 --- a/webclient/src/websocket/commands/game/setCardCounter.ts +++ b/webclient/src/websocket/commands/game/setCardCounter.ts @@ -1,8 +1,7 @@ import { create } from '@bufbuild/protobuf'; +import { Command_SetCardCounter_ext, Command_SetCardCounterSchema, type SetCardCounterParams } from '@app/generated'; import { WebClient } from '../../WebClient'; -import { Data } from '@app/types'; - -export function setCardCounter(gameId: number, params: Data.SetCardCounterParams): void { - WebClient.instance.protobuf.sendGameCommand(gameId, Data.Command_SetCardCounter_ext, create(Data.Command_SetCardCounterSchema, params)); +export function setCardCounter(gameId: number, params: SetCardCounterParams): void { + WebClient.instance.protobuf.sendGameCommand(gameId, Command_SetCardCounter_ext, create(Command_SetCardCounterSchema, params)); } diff --git a/webclient/src/websocket/commands/game/setCounter.ts b/webclient/src/websocket/commands/game/setCounter.ts index 8dc163e93..290c1b515 100644 --- a/webclient/src/websocket/commands/game/setCounter.ts +++ b/webclient/src/websocket/commands/game/setCounter.ts @@ -1,8 +1,7 @@ import { create } from '@bufbuild/protobuf'; +import { Command_SetCounter_ext, Command_SetCounterSchema, type SetCounterParams } from '@app/generated'; import { WebClient } from '../../WebClient'; -import { Data } from '@app/types'; - -export function setCounter(gameId: number, params: Data.SetCounterParams): void { - WebClient.instance.protobuf.sendGameCommand(gameId, Data.Command_SetCounter_ext, create(Data.Command_SetCounterSchema, params)); +export function setCounter(gameId: number, params: SetCounterParams): void { + WebClient.instance.protobuf.sendGameCommand(gameId, Command_SetCounter_ext, create(Command_SetCounterSchema, params)); } diff --git a/webclient/src/websocket/commands/game/setSideboardLock.ts b/webclient/src/websocket/commands/game/setSideboardLock.ts index 3a5ea21ab..cc7844d36 100644 --- a/webclient/src/websocket/commands/game/setSideboardLock.ts +++ b/webclient/src/websocket/commands/game/setSideboardLock.ts @@ -1,12 +1,11 @@ import { create } from '@bufbuild/protobuf'; +import { Command_SetSideboardLock_ext, Command_SetSideboardLockSchema, type SetSideboardLockParams } from '@app/generated'; import { WebClient } from '../../WebClient'; -import { Data } from '@app/types'; - -export function setSideboardLock(gameId: number, params: Data.SetSideboardLockParams): void { +export function setSideboardLock(gameId: number, params: SetSideboardLockParams): void { WebClient.instance.protobuf.sendGameCommand( gameId, - Data.Command_SetSideboardLock_ext, - create(Data.Command_SetSideboardLockSchema, params) + Command_SetSideboardLock_ext, + create(Command_SetSideboardLockSchema, params) ); } diff --git a/webclient/src/websocket/commands/game/setSideboardPlan.ts b/webclient/src/websocket/commands/game/setSideboardPlan.ts index 9472a0df6..8caa495c2 100644 --- a/webclient/src/websocket/commands/game/setSideboardPlan.ts +++ b/webclient/src/websocket/commands/game/setSideboardPlan.ts @@ -1,12 +1,11 @@ import { create } from '@bufbuild/protobuf'; +import { Command_SetSideboardPlan_ext, Command_SetSideboardPlanSchema, type SetSideboardPlanParams } from '@app/generated'; import { WebClient } from '../../WebClient'; -import { Data } from '@app/types'; - -export function setSideboardPlan(gameId: number, params: Data.SetSideboardPlanParams): void { +export function setSideboardPlan(gameId: number, params: SetSideboardPlanParams): void { WebClient.instance.protobuf.sendGameCommand( gameId, - Data.Command_SetSideboardPlan_ext, - create(Data.Command_SetSideboardPlanSchema, params) + Command_SetSideboardPlan_ext, + create(Command_SetSideboardPlanSchema, params) ); } diff --git a/webclient/src/websocket/commands/game/shuffle.ts b/webclient/src/websocket/commands/game/shuffle.ts index 688bd6bb0..9e9a6772c 100644 --- a/webclient/src/websocket/commands/game/shuffle.ts +++ b/webclient/src/websocket/commands/game/shuffle.ts @@ -1,8 +1,7 @@ import { create } from '@bufbuild/protobuf'; +import { Command_Shuffle_ext, Command_ShuffleSchema, type ShuffleParams } from '@app/generated'; import { WebClient } from '../../WebClient'; -import { Data } from '@app/types'; - -export function shuffle(gameId: number, params: Data.ShuffleParams): void { - WebClient.instance.protobuf.sendGameCommand(gameId, Data.Command_Shuffle_ext, create(Data.Command_ShuffleSchema, params)); +export function shuffle(gameId: number, params: ShuffleParams): void { + WebClient.instance.protobuf.sendGameCommand(gameId, Command_Shuffle_ext, create(Command_ShuffleSchema, params)); } diff --git a/webclient/src/websocket/commands/game/unconcede.ts b/webclient/src/websocket/commands/game/unconcede.ts index 5569e76ed..505b5991c 100644 --- a/webclient/src/websocket/commands/game/unconcede.ts +++ b/webclient/src/websocket/commands/game/unconcede.ts @@ -1,7 +1,7 @@ import { create } from '@bufbuild/protobuf'; +import { Command_Unconcede_ext, Command_UnconcedeSchema } from '@app/generated'; import { WebClient } from '../../WebClient'; -import { Data } from '@app/types'; 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)); } diff --git a/webclient/src/websocket/commands/game/undoDraw.ts b/webclient/src/websocket/commands/game/undoDraw.ts index 0f0677352..b11e76d29 100644 --- a/webclient/src/websocket/commands/game/undoDraw.ts +++ b/webclient/src/websocket/commands/game/undoDraw.ts @@ -1,7 +1,7 @@ import { create } from '@bufbuild/protobuf'; +import { Command_UndoDraw_ext, Command_UndoDrawSchema } from '@app/generated'; import { WebClient } from '../../WebClient'; -import { Data } from '@app/types'; 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)); } diff --git a/webclient/src/websocket/commands/moderator/banFromServer.ts b/webclient/src/websocket/commands/moderator/banFromServer.ts index 7bb0f9989..d2c2bae64 100644 --- a/webclient/src/websocket/commands/moderator/banFromServer.ts +++ b/webclient/src/websocket/commands/moderator/banFromServer.ts @@ -1,11 +1,11 @@ import { create } from '@bufbuild/protobuf'; 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, 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 }), { onSuccess: () => { diff --git a/webclient/src/websocket/commands/moderator/forceActivateUser.ts b/webclient/src/websocket/commands/moderator/forceActivateUser.ts index 53ff315ba..6b7625809 100644 --- a/webclient/src/websocket/commands/moderator/forceActivateUser.ts +++ b/webclient/src/websocket/commands/moderator/forceActivateUser.ts @@ -1,11 +1,11 @@ import { create } from '@bufbuild/protobuf'; 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 { - const cmd = create(Data.Command_ForceActivateUserSchema, { usernameToActivate, moderatorName }); - WebClient.instance.protobuf.sendModeratorCommand(Data.Command_ForceActivateUser_ext, cmd, { + const cmd = create(Command_ForceActivateUserSchema, { usernameToActivate, moderatorName }); + WebClient.instance.protobuf.sendModeratorCommand(Command_ForceActivateUser_ext, cmd, { onSuccess: () => { WebClient.instance.response.moderator.forceActivateUser(usernameToActivate, moderatorName); }, diff --git a/webclient/src/websocket/commands/moderator/getAdminNotes.ts b/webclient/src/websocket/commands/moderator/getAdminNotes.ts index 734a9d041..6339b8b77 100644 --- a/webclient/src/websocket/commands/moderator/getAdminNotes.ts +++ b/webclient/src/websocket/commands/moderator/getAdminNotes.ts @@ -1,11 +1,11 @@ import { create } from '@bufbuild/protobuf'; 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 { - WebClient.instance.protobuf.sendModeratorCommand(Data.Command_GetAdminNotes_ext, create(Data.Command_GetAdminNotesSchema, { userName }), { - responseExt: Data.Response_GetAdminNotes_ext, + WebClient.instance.protobuf.sendModeratorCommand(Command_GetAdminNotes_ext, create(Command_GetAdminNotesSchema, { userName }), { + responseExt: Response_GetAdminNotes_ext, onSuccess: (response) => { WebClient.instance.response.moderator.getAdminNotes(userName, response.notes); }, diff --git a/webclient/src/websocket/commands/moderator/getBanHistory.ts b/webclient/src/websocket/commands/moderator/getBanHistory.ts index c22e0d624..ecc48fb26 100644 --- a/webclient/src/websocket/commands/moderator/getBanHistory.ts +++ b/webclient/src/websocket/commands/moderator/getBanHistory.ts @@ -1,11 +1,11 @@ import { create } from '@bufbuild/protobuf'; 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 { - WebClient.instance.protobuf.sendModeratorCommand(Data.Command_GetBanHistory_ext, create(Data.Command_GetBanHistorySchema, { userName }), { - responseExt: Data.Response_BanHistory_ext, + WebClient.instance.protobuf.sendModeratorCommand(Command_GetBanHistory_ext, create(Command_GetBanHistorySchema, { userName }), { + responseExt: Response_BanHistory_ext, onSuccess: (response) => { WebClient.instance.response.moderator.banHistory(userName, response.banList); }, diff --git a/webclient/src/websocket/commands/moderator/getWarnHistory.ts b/webclient/src/websocket/commands/moderator/getWarnHistory.ts index 0aa4f6646..9f27cf2c6 100644 --- a/webclient/src/websocket/commands/moderator/getWarnHistory.ts +++ b/webclient/src/websocket/commands/moderator/getWarnHistory.ts @@ -1,14 +1,14 @@ import { create } from '@bufbuild/protobuf'; 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 { WebClient.instance.protobuf.sendModeratorCommand( - Data.Command_GetWarnHistory_ext, - create(Data.Command_GetWarnHistorySchema, { userName }), + Command_GetWarnHistory_ext, + create(Command_GetWarnHistorySchema, { userName }), { - responseExt: Data.Response_WarnHistory_ext, + responseExt: Response_WarnHistory_ext, onSuccess: (response) => { WebClient.instance.response.moderator.warnHistory(userName, response.warnList); }, diff --git a/webclient/src/websocket/commands/moderator/getWarnList.ts b/webclient/src/websocket/commands/moderator/getWarnList.ts index 9a734727c..262cba2df 100644 --- a/webclient/src/websocket/commands/moderator/getWarnList.ts +++ b/webclient/src/websocket/commands/moderator/getWarnList.ts @@ -1,14 +1,14 @@ import { create } from '@bufbuild/protobuf'; 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 { WebClient.instance.protobuf.sendModeratorCommand( - Data.Command_GetWarnList_ext, - create(Data.Command_GetWarnListSchema, { modName, userName, userClientid }), + Command_GetWarnList_ext, + create(Command_GetWarnListSchema, { modName, userName, userClientid }), { - responseExt: Data.Response_WarnList_ext, + responseExt: Response_WarnList_ext, onSuccess: (response) => { WebClient.instance.response.moderator.warnListOptions([response]); }, diff --git a/webclient/src/websocket/commands/moderator/grantReplayAccess.ts b/webclient/src/websocket/commands/moderator/grantReplayAccess.ts index 47def64d0..0b288efc7 100644 --- a/webclient/src/websocket/commands/moderator/grantReplayAccess.ts +++ b/webclient/src/websocket/commands/moderator/grantReplayAccess.ts @@ -1,12 +1,12 @@ import { create } from '@bufbuild/protobuf'; 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 { WebClient.instance.protobuf.sendModeratorCommand( - Data.Command_GrantReplayAccess_ext, - create(Data.Command_GrantReplayAccessSchema, { replayId, moderatorName }), + Command_GrantReplayAccess_ext, + create(Command_GrantReplayAccessSchema, { replayId, moderatorName }), { onSuccess: () => { WebClient.instance.response.moderator.grantReplayAccess(replayId, moderatorName); diff --git a/webclient/src/websocket/commands/moderator/moderatorCommands.spec.ts b/webclient/src/websocket/commands/moderator/moderatorCommands.spec.ts index dea409034..69ba240fd 100644 --- a/webclient/src/websocket/commands/moderator/moderatorCommands.spec.ts +++ b/webclient/src/websocket/commands/moderator/moderatorCommands.spec.ts @@ -1,28 +1,25 @@ -vi.mock('../../WebClient', () => ({ - WebClient: { - instance: { - protobuf: { sendModeratorCommand: vi.fn() }, - response: { - moderator: { - banFromServer: vi.fn(), - forceActivateUser: vi.fn(), - getAdminNotes: vi.fn(), - banHistory: vi.fn(), - warnHistory: vi.fn(), - warnListOptions: vi.fn(), - grantReplayAccess: vi.fn(), - updateAdminNotes: vi.fn(), - viewLogs: vi.fn(), - warnUser: vi.fn(), - }, - }, - }, - }, -})); +vi.mock('../../WebClient'); import { makeCallbackHelpers } from '../../__mocks__/callbackHelpers'; 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 { forceActivateUser } from './forceActivateUser'; @@ -50,7 +47,7 @@ describe('banFromServer', () => { it('calls sendModeratorCommand with Command_BanFromServer', () => { banFromServer(30, 'alice', '1.2.3.4', 'reason', 'visible', 'cid', 1); expect(WebClient.instance.protobuf.sendModeratorCommand).toHaveBeenCalledWith( - Data.Command_BanFromServer_ext, + Command_BanFromServer_ext, expect.objectContaining({ minutes: 30, userName: 'alice' }), expect.any(Object) ); @@ -71,7 +68,7 @@ describe('forceActivateUser', () => { it('calls sendModeratorCommand with Command_ForceActivateUser', () => { forceActivateUser('alice', 'mod1'); 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 +87,9 @@ describe('getAdminNotes', () => { it('calls sendModeratorCommand with Command_GetAdminNotes', () => { getAdminNotes('alice'); expect(WebClient.instance.protobuf.sendModeratorCommand).toHaveBeenCalledWith( - Data.Command_GetAdminNotes_ext, + Command_GetAdminNotes_ext, expect.any(Object), - expect.objectContaining({ responseExt: Data.Response_GetAdminNotes_ext }) + expect.objectContaining({ responseExt: Response_GetAdminNotes_ext }) ); }); @@ -112,9 +109,9 @@ describe('getBanHistory', () => { it('calls sendModeratorCommand with Command_GetBanHistory', () => { getBanHistory('alice'); expect(WebClient.instance.protobuf.sendModeratorCommand).toHaveBeenCalledWith( - Data.Command_GetBanHistory_ext, + Command_GetBanHistory_ext, expect.any(Object), - expect.objectContaining({ responseExt: Data.Response_BanHistory_ext }) + expect.objectContaining({ responseExt: Response_BanHistory_ext }) ); }); @@ -134,9 +131,9 @@ describe('getWarnHistory', () => { it('calls sendModeratorCommand with Command_GetWarnHistory', () => { getWarnHistory('alice'); expect(WebClient.instance.protobuf.sendModeratorCommand).toHaveBeenCalledWith( - Data.Command_GetWarnHistory_ext, + Command_GetWarnHistory_ext, expect.any(Object), - expect.objectContaining({ responseExt: Data.Response_WarnHistory_ext }) + expect.objectContaining({ responseExt: Response_WarnHistory_ext }) ); }); @@ -156,9 +153,9 @@ describe('getWarnList', () => { it('calls sendModeratorCommand with Command_GetWarnList', () => { getWarnList('mod1', 'alice', 'US'); expect(WebClient.instance.protobuf.sendModeratorCommand).toHaveBeenCalledWith( - Data.Command_GetWarnList_ext, + Command_GetWarnList_ext, expect.any(Object), - expect.objectContaining({ responseExt: Data.Response_WarnList_ext }) + expect.objectContaining({ responseExt: Response_WarnList_ext }) ); }); @@ -178,7 +175,7 @@ describe('grantReplayAccess', () => { it('calls sendModeratorCommand with Command_GrantReplayAccess', () => { grantReplayAccess(10, 'mod1'); 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 +194,7 @@ describe('updateAdminNotes', () => { it('calls sendModeratorCommand with Command_UpdateAdminNotes', () => { updateAdminNotes('alice', 'new notes'); 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 +211,17 @@ describe('updateAdminNotes', () => { describe('viewLogHistory', () => { it('calls sendModeratorCommand with Command_ViewLogHistory', () => { - const filters = create(Data.Command_ViewLogHistorySchema, { dateRange: 7 }); + const filters = create(Command_ViewLogHistorySchema, { dateRange: 7 }); viewLogHistory(filters); expect(WebClient.instance.protobuf.sendModeratorCommand).toHaveBeenCalledWith( - Data.Command_ViewLogHistory_ext, + Command_ViewLogHistory_ext, expect.any(Object), - expect.objectContaining({ responseExt: Data.Response_ViewLogHistory_ext }) + expect.objectContaining({ responseExt: Response_ViewLogHistory_ext }) ); }); 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); const resp = { logMessage: ['log1'] }; invokeOnSuccess(resp, { responseCode: 0 }); @@ -240,7 +237,7 @@ describe('warnUser', () => { it('calls sendModeratorCommand with Command_WarnUser', () => { warnUser('alice', 'bad behavior', 'cid'); 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) ); }); diff --git a/webclient/src/websocket/commands/moderator/updateAdminNotes.ts b/webclient/src/websocket/commands/moderator/updateAdminNotes.ts index dba02650d..7d760938d 100644 --- a/webclient/src/websocket/commands/moderator/updateAdminNotes.ts +++ b/webclient/src/websocket/commands/moderator/updateAdminNotes.ts @@ -1,12 +1,12 @@ import { create } from '@bufbuild/protobuf'; 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 { WebClient.instance.protobuf.sendModeratorCommand( - Data.Command_UpdateAdminNotes_ext, - create(Data.Command_UpdateAdminNotesSchema, { userName, notes }), + Command_UpdateAdminNotes_ext, + create(Command_UpdateAdminNotesSchema, { userName, notes }), { onSuccess: () => { WebClient.instance.response.moderator.updateAdminNotes(userName, notes); diff --git a/webclient/src/websocket/commands/moderator/viewLogHistory.ts b/webclient/src/websocket/commands/moderator/viewLogHistory.ts index 956af083e..e222899f0 100644 --- a/webclient/src/websocket/commands/moderator/viewLogHistory.ts +++ b/webclient/src/websocket/commands/moderator/viewLogHistory.ts @@ -1,11 +1,12 @@ import { create } from '@bufbuild/protobuf'; 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 { - WebClient.instance.protobuf.sendModeratorCommand(Data.Command_ViewLogHistory_ext, create(Data.Command_ViewLogHistorySchema, filters), { - responseExt: Data.Response_ViewLogHistory_ext, +export function viewLogHistory(filters: ViewLogHistoryParams): void { + WebClient.instance.protobuf.sendModeratorCommand(Command_ViewLogHistory_ext, create(Command_ViewLogHistorySchema, filters), { + responseExt: Response_ViewLogHistory_ext, onSuccess: (response) => { WebClient.instance.response.moderator.viewLogs(response.logMessage); }, diff --git a/webclient/src/websocket/commands/moderator/warnUser.ts b/webclient/src/websocket/commands/moderator/warnUser.ts index 13a521e59..22d25ac75 100644 --- a/webclient/src/websocket/commands/moderator/warnUser.ts +++ b/webclient/src/websocket/commands/moderator/warnUser.ts @@ -1,11 +1,11 @@ import { create } from '@bufbuild/protobuf'; 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 { - const cmd = create(Data.Command_WarnUserSchema, { userName, reason, clientid, removeMessages }); - WebClient.instance.protobuf.sendModeratorCommand(Data.Command_WarnUser_ext, cmd, { + const cmd = create(Command_WarnUserSchema, { userName, reason, clientid, removeMessages }); + WebClient.instance.protobuf.sendModeratorCommand(Command_WarnUser_ext, cmd, { onSuccess: () => { WebClient.instance.response.moderator.warnUser(userName); }, diff --git a/webclient/src/websocket/commands/room/createGame.ts b/webclient/src/websocket/commands/room/createGame.ts index 045cb0175..133e8a396 100644 --- a/webclient/src/websocket/commands/room/createGame.ts +++ b/webclient/src/websocket/commands/room/createGame.ts @@ -1,10 +1,11 @@ import { create } from '@bufbuild/protobuf'; 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 { - WebClient.instance.protobuf.sendRoomCommand(roomId, Data.Command_CreateGame_ext, create(Data.Command_CreateGameSchema, gameConfig), { +export function createGame(roomId: number, gameConfig: CreateGameParams): void { + WebClient.instance.protobuf.sendRoomCommand(roomId, Command_CreateGame_ext, create(Command_CreateGameSchema, gameConfig), { onSuccess: () => { WebClient.instance.response.room.gameCreated(roomId); }, diff --git a/webclient/src/websocket/commands/room/joinGame.ts b/webclient/src/websocket/commands/room/joinGame.ts index dbaa6ea95..a19721746 100644 --- a/webclient/src/websocket/commands/room/joinGame.ts +++ b/webclient/src/websocket/commands/room/joinGame.ts @@ -1,10 +1,11 @@ import { create } from '@bufbuild/protobuf'; 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 { - WebClient.instance.protobuf.sendRoomCommand(roomId, Data.Command_JoinGame_ext, create(Data.Command_JoinGameSchema, joinGameParams), { +export function joinGame(roomId: number, joinGameParams: JoinGameParams): void { + WebClient.instance.protobuf.sendRoomCommand(roomId, Command_JoinGame_ext, create(Command_JoinGameSchema, joinGameParams), { onSuccess: () => { WebClient.instance.response.room.joinedGame(roomId, joinGameParams.gameId); }, diff --git a/webclient/src/websocket/commands/room/leaveRoom.ts b/webclient/src/websocket/commands/room/leaveRoom.ts index 8f61ee652..004e704c0 100644 --- a/webclient/src/websocket/commands/room/leaveRoom.ts +++ b/webclient/src/websocket/commands/room/leaveRoom.ts @@ -1,10 +1,10 @@ import { create } from '@bufbuild/protobuf'; import { WebClient } from '../../WebClient'; -import { Data } from '@app/types'; +import { Command_LeaveRoom_ext, Command_LeaveRoomSchema } from '@app/generated'; 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: () => { WebClient.instance.response.room.leaveRoom(roomId); }, diff --git a/webclient/src/websocket/commands/room/roomCommands.spec.ts b/webclient/src/websocket/commands/room/roomCommands.spec.ts index 5c432f9d6..57dac994a 100644 --- a/webclient/src/websocket/commands/room/roomCommands.spec.ts +++ b/webclient/src/websocket/commands/room/roomCommands.spec.ts @@ -1,21 +1,15 @@ -vi.mock('../../WebClient', () => ({ - WebClient: { - instance: { - protobuf: { sendRoomCommand: vi.fn() }, - response: { - room: { - gameCreated: vi.fn(), - joinedGame: vi.fn(), - leaveRoom: vi.fn(), - }, - }, - }, - }, -})); +vi.mock('../../WebClient'); import { makeCallbackHelpers } from '../../__mocks__/callbackHelpers'; 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 { joinGame } from './joinGame'; @@ -36,14 +30,14 @@ const { invokeOnSuccess } = makeCallbackHelpers( describe('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( - 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', () => { - createGame(5, create(Data.Command_CreateGameSchema, {})); + createGame(5, create(Command_CreateGameSchema, {})); invokeOnSuccess(); expect(WebClient.instance.response.room.gameCreated).toHaveBeenCalledWith(5); }); @@ -55,14 +49,14 @@ describe('createGame', () => { describe('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( - 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', () => { - joinGame(7, create(Data.Command_JoinGameSchema, { gameId: 42 })); + joinGame(7, create(Command_JoinGameSchema, { gameId: 42 })); invokeOnSuccess(); expect(WebClient.instance.response.room.joinedGame).toHaveBeenCalledWith(7, 42); }); @@ -76,7 +70,7 @@ describe('leaveRoom', () => { it('calls sendRoomCommand with Command_LeaveRoom', () => { leaveRoom(3); 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 +90,7 @@ describe('roomSay', () => { roomSay(2, ' hello '); expect(WebClient.instance.protobuf.sendRoomCommand).toHaveBeenCalledWith( 2, - Data.Command_RoomSay_ext, + Command_RoomSay_ext, expect.objectContaining({ message: 'hello' }) ); }); diff --git a/webclient/src/websocket/commands/room/roomSay.ts b/webclient/src/websocket/commands/room/roomSay.ts index ac5f964d2..2b3618420 100644 --- a/webclient/src/websocket/commands/room/roomSay.ts +++ b/webclient/src/websocket/commands/room/roomSay.ts @@ -1,6 +1,6 @@ import { create } from '@bufbuild/protobuf'; 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 { const trimmed = message.trim(); @@ -9,5 +9,5 @@ export function roomSay(roomId: number, message: string): void { 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 })); } diff --git a/webclient/src/websocket/commands/session/accountEdit.ts b/webclient/src/websocket/commands/session/accountEdit.ts index 60b361c57..be44b19e8 100644 --- a/webclient/src/websocket/commands/session/accountEdit.ts +++ b/webclient/src/websocket/commands/session/accountEdit.ts @@ -1,11 +1,11 @@ import { create } from '@bufbuild/protobuf'; 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 { - const cmd = create(Data.Command_AccountEditSchema, { passwordCheck, realName, email, country }); - WebClient.instance.protobuf.sendSessionCommand(Data.Command_AccountEdit_ext, cmd, { + const cmd = create(Command_AccountEditSchema, { passwordCheck, realName, email, country }); + WebClient.instance.protobuf.sendSessionCommand(Command_AccountEdit_ext, cmd, { onSuccess: () => { WebClient.instance.response.session.accountEditChanged(realName, email, country); }, diff --git a/webclient/src/websocket/commands/session/accountImage.ts b/webclient/src/websocket/commands/session/accountImage.ts index b14181ff2..47df13c92 100644 --- a/webclient/src/websocket/commands/session/accountImage.ts +++ b/webclient/src/websocket/commands/session/accountImage.ts @@ -1,10 +1,10 @@ import { create } from '@bufbuild/protobuf'; import { WebClient } from '../../WebClient'; -import { Data } from '@app/types'; +import { Command_AccountImage_ext, Command_AccountImageSchema } from '@app/generated'; 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: () => { WebClient.instance.response.session.accountImageChanged(image); }, diff --git a/webclient/src/websocket/commands/session/accountPassword.ts b/webclient/src/websocket/commands/session/accountPassword.ts index 2f5476461..236c2e162 100644 --- a/webclient/src/websocket/commands/session/accountPassword.ts +++ b/webclient/src/websocket/commands/session/accountPassword.ts @@ -1,11 +1,11 @@ import { create } from '@bufbuild/protobuf'; 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 { - const cmd = create(Data.Command_AccountPasswordSchema, { oldPassword, newPassword, hashedNewPassword }); - WebClient.instance.protobuf.sendSessionCommand(Data.Command_AccountPassword_ext, cmd, { + const cmd = create(Command_AccountPasswordSchema, { oldPassword, newPassword, hashedNewPassword }); + WebClient.instance.protobuf.sendSessionCommand(Command_AccountPassword_ext, cmd, { onSuccess: () => { WebClient.instance.response.session.accountPasswordChange(); }, diff --git a/webclient/src/websocket/commands/session/activate.ts b/webclient/src/websocket/commands/session/activate.ts index b9dffea40..422ed937f 100644 --- a/webclient/src/websocket/commands/session/activate.ts +++ b/webclient/src/websocket/commands/session/activate.ts @@ -1,32 +1,37 @@ -import { App, Enriched, Data } from '@app/types'; - import { create } from '@bufbuild/protobuf'; +import { + Command_Activate_ext, + Command_ActivateSchema, + Response_ResponseCode, + type ActivateParams, +} from '@app/generated'; + +import { StatusEnum } from '../../interfaces/StatusEnum'; import { CLIENT_CONFIG } from '../../config'; import { WebClient } from '../../WebClient'; - +import type { ConnectTarget } from '../../interfaces/WebClientConfig'; import { disconnect, login, updateStatus } from './'; -export function activate(options: Omit, password?: string, passwordSalt?: string): void { +export function activate(options: ConnectTarget & ActivateParams, password?: string, passwordSalt?: string): void { 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, userName, token, }), { onResponseCode: { - [Data.Response_ResponseCode.RespActivationAccepted]: () => { + [Response_ResponseCode.RespActivationAccepted]: () => { WebClient.instance.response.session.accountActivationSuccess(); login({ host: options.host, port: options.port, userName: options.userName, - reason: App.WebSocketConnectReason.LOGIN, }, password, passwordSalt); }, }, onError: () => { - updateStatus(App.StatusEnum.DISCONNECTED, 'Account Activation Failed'); + updateStatus(StatusEnum.DISCONNECTED, 'Account Activation Failed'); disconnect(); WebClient.instance.response.session.accountActivationFailed(); }, diff --git a/webclient/src/websocket/commands/session/addToList.ts b/webclient/src/websocket/commands/session/addToList.ts index d7d36db57..76836cb38 100644 --- a/webclient/src/websocket/commands/session/addToList.ts +++ b/webclient/src/websocket/commands/session/addToList.ts @@ -1,7 +1,7 @@ import { create } from '@bufbuild/protobuf'; import { WebClient } from '../../WebClient'; -import { Data } from '@app/types'; +import { Command_AddToList_ext, Command_AddToListSchema } from '@app/generated'; export function addToBuddyList(userName: string): void { addToList('buddy', userName); @@ -12,7 +12,7 @@ export function addToIgnoreList(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: () => { WebClient.instance.response.session.addToList(list, userName); }, diff --git a/webclient/src/websocket/commands/session/connect.ts b/webclient/src/websocket/commands/session/connect.ts index df494cbbb..035f5a60a 100644 --- a/webclient/src/websocket/commands/session/connect.ts +++ b/webclient/src/websocket/commands/session/connect.ts @@ -1,25 +1,10 @@ -import { App, Enriched } from '@app/types'; import { WebClient } from '../../WebClient'; -import { updateStatus } from './'; +import type { ConnectTarget } from '../../interfaces/WebClientConfig'; -export function connect(options: Enriched.WebSocketConnectOptions): void { - switch (options.reason) { - case App.WebSocketConnectReason.LOGIN: - case App.WebSocketConnectReason.REGISTER: - case App.WebSocketConnectReason.ACTIVATE_ACCOUNT: - case App.WebSocketConnectReason.PASSWORD_RESET_REQUEST: - 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; - } - } +export function connect(target: ConnectTarget): void { + WebClient.instance.connect(target); +} + +export function testConnect(target: ConnectTarget): void { + WebClient.instance.testConnect(target); } diff --git a/webclient/src/websocket/commands/session/deckDel.ts b/webclient/src/websocket/commands/session/deckDel.ts index bff1bf859..e5cc88483 100644 --- a/webclient/src/websocket/commands/session/deckDel.ts +++ b/webclient/src/websocket/commands/session/deckDel.ts @@ -1,10 +1,10 @@ import { create } from '@bufbuild/protobuf'; import { WebClient } from '../../WebClient'; -import { Data } from '@app/types'; +import { Command_DeckDel_ext, Command_DeckDelSchema } from '@app/generated'; 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: () => { WebClient.instance.response.session.deleteServerDeck(deckId); }, diff --git a/webclient/src/websocket/commands/session/deckDelDir.ts b/webclient/src/websocket/commands/session/deckDelDir.ts index 310f18907..740bbd02b 100644 --- a/webclient/src/websocket/commands/session/deckDelDir.ts +++ b/webclient/src/websocket/commands/session/deckDelDir.ts @@ -1,10 +1,10 @@ import { create } from '@bufbuild/protobuf'; import { WebClient } from '../../WebClient'; -import { Data } from '@app/types'; +import { Command_DeckDelDir_ext, Command_DeckDelDirSchema } from '@app/generated'; 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: () => { WebClient.instance.response.session.deleteServerDeckDir(path); }, diff --git a/webclient/src/websocket/commands/session/deckDownload.ts b/webclient/src/websocket/commands/session/deckDownload.ts index baffbf262..566e56272 100644 --- a/webclient/src/websocket/commands/session/deckDownload.ts +++ b/webclient/src/websocket/commands/session/deckDownload.ts @@ -1,14 +1,14 @@ import { create } from '@bufbuild/protobuf'; 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 { WebClient.instance.protobuf.sendSessionCommand( - Data.Command_DeckDownload_ext, - create(Data.Command_DeckDownloadSchema, { deckId }), + Command_DeckDownload_ext, + create(Command_DeckDownloadSchema, { deckId }), { - responseExt: Data.Response_DeckDownload_ext, + responseExt: Response_DeckDownload_ext, onSuccess: (response) => { WebClient.instance.response.session.downloadServerDeck(deckId, response); }, diff --git a/webclient/src/websocket/commands/session/deckList.ts b/webclient/src/websocket/commands/session/deckList.ts index b9afc6440..e0940010b 100644 --- a/webclient/src/websocket/commands/session/deckList.ts +++ b/webclient/src/websocket/commands/session/deckList.ts @@ -1,11 +1,11 @@ import { create } from '@bufbuild/protobuf'; 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 { - WebClient.instance.protobuf.sendSessionCommand(Data.Command_DeckList_ext, create(Data.Command_DeckListSchema), { - responseExt: Data.Response_DeckList_ext, + WebClient.instance.protobuf.sendSessionCommand(Command_DeckList_ext, create(Command_DeckListSchema), { + responseExt: Response_DeckList_ext, onSuccess: (response) => { if (response.root) { WebClient.instance.response.session.updateServerDecks(response); diff --git a/webclient/src/websocket/commands/session/deckNewDir.ts b/webclient/src/websocket/commands/session/deckNewDir.ts index 0786936da..dc27fb0ca 100644 --- a/webclient/src/websocket/commands/session/deckNewDir.ts +++ b/webclient/src/websocket/commands/session/deckNewDir.ts @@ -1,10 +1,10 @@ import { create } from '@bufbuild/protobuf'; 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 { - 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: () => { WebClient.instance.response.session.createServerDeckDir(path, dirName); }, diff --git a/webclient/src/websocket/commands/session/deckUpload.ts b/webclient/src/websocket/commands/session/deckUpload.ts index 7f0c0de67..8b4c647ba 100644 --- a/webclient/src/websocket/commands/session/deckUpload.ts +++ b/webclient/src/websocket/commands/session/deckUpload.ts @@ -1,14 +1,14 @@ import { create } from '@bufbuild/protobuf'; 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 { WebClient.instance.protobuf.sendSessionCommand( - Data.Command_DeckUpload_ext, - create(Data.Command_DeckUploadSchema, { path, deckId, deckList }), + Command_DeckUpload_ext, + create(Command_DeckUploadSchema, { path, deckId, deckList }), { - responseExt: Data.Response_DeckUpload_ext, + responseExt: Response_DeckUpload_ext, onSuccess: (response) => { if (response.newFile) { WebClient.instance.response.session.uploadServerDeck(path, response.newFile); diff --git a/webclient/src/websocket/commands/session/forgotPasswordChallenge.ts b/webclient/src/websocket/commands/session/forgotPasswordChallenge.ts index fb4e7d468..7246580af 100644 --- a/webclient/src/websocket/commands/session/forgotPasswordChallenge.ts +++ b/webclient/src/websocket/commands/session/forgotPasswordChallenge.ts @@ -1,29 +1,34 @@ -import { App, Enriched, Data } from '@app/types'; - import { create } from '@bufbuild/protobuf'; +import { + Command_ForgotPasswordChallenge_ext, + Command_ForgotPasswordChallengeSchema, + type ForgotPasswordChallengeParams, +} from '@app/generated'; + +import { StatusEnum } from '../../interfaces/StatusEnum'; import { CLIENT_CONFIG } from '../../config'; import { WebClient } from '../../WebClient'; - +import type { ConnectTarget } from '../../interfaces/WebClientConfig'; import { disconnect, updateStatus } from './'; -export function forgotPasswordChallenge(options: Enriched.PasswordResetChallengeConnectOptions): void { +export function forgotPasswordChallenge(options: ConnectTarget & ForgotPasswordChallengeParams): void { const { userName, email } = options; WebClient.instance.protobuf.sendSessionCommand( - Data.Command_ForgotPasswordChallenge_ext, - create(Data.Command_ForgotPasswordChallengeSchema, { + Command_ForgotPasswordChallenge_ext, + create(Command_ForgotPasswordChallengeSchema, { ...CLIENT_CONFIG, userName, email, }), { onSuccess: () => { - updateStatus(App.StatusEnum.DISCONNECTED, null); + updateStatus(StatusEnum.DISCONNECTED, null); WebClient.instance.response.session.resetPassword(); disconnect(); }, onError: () => { - updateStatus(App.StatusEnum.DISCONNECTED, null); + updateStatus(StatusEnum.DISCONNECTED, null); WebClient.instance.response.session.resetPasswordFailed(); disconnect(); }, diff --git a/webclient/src/websocket/commands/session/forgotPasswordRequest.ts b/webclient/src/websocket/commands/session/forgotPasswordRequest.ts index ddc268c5a..cf8246b30 100644 --- a/webclient/src/websocket/commands/session/forgotPasswordRequest.ts +++ b/webclient/src/websocket/commands/session/forgotPasswordRequest.ts @@ -1,31 +1,37 @@ -import { App, Enriched, Data } from '@app/types'; - import { create } from '@bufbuild/protobuf'; +import { + Command_ForgotPasswordRequest_ext, + Command_ForgotPasswordRequestSchema, + Response_ForgotPasswordRequest_ext, + type ForgotPasswordRequestParams, +} from '@app/generated'; + +import { StatusEnum } from '../../interfaces/StatusEnum'; import { CLIENT_CONFIG } from '../../config'; import { WebClient } from '../../WebClient'; - +import type { ConnectTarget } from '../../interfaces/WebClientConfig'; import { disconnect, updateStatus } from './'; -export function forgotPasswordRequest(options: Enriched.PasswordResetRequestConnectOptions): void { +export function forgotPasswordRequest(options: ConnectTarget & ForgotPasswordRequestParams): void { const { userName } = options; - WebClient.instance.protobuf.sendSessionCommand(Data.Command_ForgotPasswordRequest_ext, create(Data.Command_ForgotPasswordRequestSchema, { + WebClient.instance.protobuf.sendSessionCommand(Command_ForgotPasswordRequest_ext, create(Command_ForgotPasswordRequestSchema, { ...CLIENT_CONFIG, userName, }), { - responseExt: Data.Response_ForgotPasswordRequest_ext, + responseExt: Response_ForgotPasswordRequest_ext, onSuccess: (resp) => { if (resp?.challengeEmail) { - updateStatus(App.StatusEnum.DISCONNECTED, null); + updateStatus(StatusEnum.DISCONNECTED, null); WebClient.instance.response.session.resetPasswordChallenge(); } else { - updateStatus(App.StatusEnum.DISCONNECTED, null); + updateStatus(StatusEnum.DISCONNECTED, null); WebClient.instance.response.session.resetPassword(); } disconnect(); }, onError: () => { - updateStatus(App.StatusEnum.DISCONNECTED, null); + updateStatus(StatusEnum.DISCONNECTED, null); WebClient.instance.response.session.resetPasswordFailed(); disconnect(); }, diff --git a/webclient/src/websocket/commands/session/forgotPasswordReset.ts b/webclient/src/websocket/commands/session/forgotPasswordReset.ts index 62b3ba84f..3acd946b7 100644 --- a/webclient/src/websocket/commands/session/forgotPasswordReset.ts +++ b/webclient/src/websocket/commands/session/forgotPasswordReset.ts @@ -1,22 +1,26 @@ -import { App, Enriched, Data } from '@app/types'; - import { create } from '@bufbuild/protobuf'; import type { MessageInitShape } from '@bufbuild/protobuf'; +import { + Command_ForgotPasswordReset_ext, + Command_ForgotPasswordResetSchema, + type ForgotPasswordResetParams, +} from '@app/generated'; + +import { StatusEnum } from '../../interfaces/StatusEnum'; import { CLIENT_CONFIG } from '../../config'; import { WebClient } from '../../WebClient'; - +import type { ConnectTarget } from '../../interfaces/WebClientConfig'; import { hashPassword } from '../../utils'; - import { disconnect, updateStatus } from '.'; export function forgotPasswordReset( - options: Omit, + options: ConnectTarget & ForgotPasswordResetParams, newPassword?: string, passwordSalt?: string ): void { const { userName, token } = options; - const params: MessageInitShape = { + const params: MessageInitShape = { ...CLIENT_CONFIG, userName, token, @@ -26,16 +30,16 @@ export function forgotPasswordReset( }; WebClient.instance.protobuf.sendSessionCommand( - Data.Command_ForgotPasswordReset_ext, - create(Data.Command_ForgotPasswordResetSchema, params), + Command_ForgotPasswordReset_ext, + create(Command_ForgotPasswordResetSchema, params), { onSuccess: () => { - updateStatus(App.StatusEnum.DISCONNECTED, null); + updateStatus(StatusEnum.DISCONNECTED, null); WebClient.instance.response.session.resetPasswordSuccess(); disconnect(); }, onError: () => { - updateStatus(App.StatusEnum.DISCONNECTED, null); + updateStatus(StatusEnum.DISCONNECTED, null); WebClient.instance.response.session.resetPasswordFailed(); disconnect(); }, diff --git a/webclient/src/websocket/commands/session/getGamesOfUser.ts b/webclient/src/websocket/commands/session/getGamesOfUser.ts index 23ec2f784..91cb2c3ec 100644 --- a/webclient/src/websocket/commands/session/getGamesOfUser.ts +++ b/webclient/src/websocket/commands/session/getGamesOfUser.ts @@ -1,11 +1,11 @@ import { create } from '@bufbuild/protobuf'; import { WebClient } from '../../WebClient'; -import { Data } from '@app/types'; +import { Command_GetGamesOfUser_ext, Command_GetGamesOfUserSchema, Response_GetGamesOfUser_ext } from '@app/generated'; export function getGamesOfUser(userName: string): void { - WebClient.instance.protobuf.sendSessionCommand(Data.Command_GetGamesOfUser_ext, create(Data.Command_GetGamesOfUserSchema, { userName }), { - responseExt: Data.Response_GetGamesOfUser_ext, + WebClient.instance.protobuf.sendSessionCommand(Command_GetGamesOfUser_ext, create(Command_GetGamesOfUserSchema, { userName }), { + responseExt: Response_GetGamesOfUser_ext, onSuccess: (response) => { WebClient.instance.response.session.getGamesOfUser(userName, response); }, diff --git a/webclient/src/websocket/commands/session/getUserInfo.ts b/webclient/src/websocket/commands/session/getUserInfo.ts index f7033d451..ff266670f 100644 --- a/webclient/src/websocket/commands/session/getUserInfo.ts +++ b/webclient/src/websocket/commands/session/getUserInfo.ts @@ -1,11 +1,11 @@ import { create } from '@bufbuild/protobuf'; import { WebClient } from '../../WebClient'; -import { Data } from '@app/types'; +import { Command_GetUserInfo_ext, Command_GetUserInfoSchema, Response_GetUserInfo_ext } from '@app/generated'; export function getUserInfo(userName: string): void { - WebClient.instance.protobuf.sendSessionCommand(Data.Command_GetUserInfo_ext, create(Data.Command_GetUserInfoSchema, { userName }), { - responseExt: Data.Response_GetUserInfo_ext, + WebClient.instance.protobuf.sendSessionCommand(Command_GetUserInfo_ext, create(Command_GetUserInfoSchema, { userName }), { + responseExt: Response_GetUserInfo_ext, onSuccess: (response) => { WebClient.instance.response.session.getUserInfo(response.userInfo); }, diff --git a/webclient/src/websocket/commands/session/joinRoom.ts b/webclient/src/websocket/commands/session/joinRoom.ts index 9c67d818d..494819208 100644 --- a/webclient/src/websocket/commands/session/joinRoom.ts +++ b/webclient/src/websocket/commands/session/joinRoom.ts @@ -1,11 +1,11 @@ import { create } from '@bufbuild/protobuf'; import { WebClient } from '../../WebClient'; -import { Data } from '@app/types'; +import { Command_JoinRoom_ext, Command_JoinRoomSchema, Response_JoinRoom_ext } from '@app/generated'; export function joinRoom(roomId: number): void { - WebClient.instance.protobuf.sendSessionCommand(Data.Command_JoinRoom_ext, create(Data.Command_JoinRoomSchema, { roomId }), { - responseExt: Data.Response_JoinRoom_ext, + WebClient.instance.protobuf.sendSessionCommand(Command_JoinRoom_ext, create(Command_JoinRoomSchema, { roomId }), { + responseExt: Response_JoinRoom_ext, onSuccess: (response) => { if (response.roomInfo) { WebClient.instance.response.room.joinRoom(response.roomInfo); diff --git a/webclient/src/websocket/commands/session/listRooms.ts b/webclient/src/websocket/commands/session/listRooms.ts index a54d12ad1..25b0837f3 100644 --- a/webclient/src/websocket/commands/session/listRooms.ts +++ b/webclient/src/websocket/commands/session/listRooms.ts @@ -1,7 +1,7 @@ import { create } from '@bufbuild/protobuf'; import { WebClient } from '../../WebClient'; -import { Data } from '@app/types'; +import { Command_ListRooms_ext, Command_ListRoomsSchema } from '@app/generated'; export function listRooms(): void { - WebClient.instance.protobuf.sendSessionCommand(Data.Command_ListRooms_ext, create(Data.Command_ListRoomsSchema)); + WebClient.instance.protobuf.sendSessionCommand(Command_ListRooms_ext, create(Command_ListRoomsSchema)); } diff --git a/webclient/src/websocket/commands/session/listUsers.ts b/webclient/src/websocket/commands/session/listUsers.ts index 7e867754c..9978cca48 100644 --- a/webclient/src/websocket/commands/session/listUsers.ts +++ b/webclient/src/websocket/commands/session/listUsers.ts @@ -1,11 +1,11 @@ import { create } from '@bufbuild/protobuf'; import { WebClient } from '../../WebClient'; -import { Data } from '@app/types'; +import { Command_ListUsers_ext, Command_ListUsersSchema, Response_ListUsers_ext } from '@app/generated'; export function listUsers(): void { - WebClient.instance.protobuf.sendSessionCommand(Data.Command_ListUsers_ext, create(Data.Command_ListUsersSchema), { - responseExt: Data.Response_ListUsers_ext, + WebClient.instance.protobuf.sendSessionCommand(Command_ListUsers_ext, create(Command_ListUsersSchema), { + responseExt: Response_ListUsers_ext, onSuccess: (response) => { WebClient.instance.response.session.updateUsers(response.userList); }, diff --git a/webclient/src/websocket/commands/session/login.ts b/webclient/src/websocket/commands/session/login.ts index e2e759612..80fddfcc7 100644 --- a/webclient/src/websocket/commands/session/login.ts +++ b/webclient/src/websocket/commands/session/login.ts @@ -1,9 +1,17 @@ -import { App, Enriched, Data } from '@app/types'; import { create } from '@bufbuild/protobuf'; import type { MessageInitShape } from '@bufbuild/protobuf'; +import { + Command_Login_ext, + Command_LoginSchema, + Response_Login_ext, + Response_ResponseCode, + type LoginParams, +} from '@app/generated'; + +import { StatusEnum } from '../../interfaces/StatusEnum'; import { CLIENT_CONFIG } from '../../config'; import { WebClient } from '../../WebClient'; - +import type { ConnectTarget } from '../../interfaces/WebClientConfig'; import { hashPassword } from '../../utils'; import { disconnect, @@ -12,7 +20,7 @@ import { updateStatus, } from './'; -export function login(options: Omit, password?: string, passwordSalt?: string): void { +export function login(options: ConnectTarget & LoginParams, password?: string, passwordSalt?: string): void { const { userName, hashedPassword } = options; const loginConfig = { @@ -22,51 +30,52 @@ export function login(options: Omit, p ...(passwordSalt ? { hashedPassword: hashedPassword || hashPassword(passwordSalt, password) } : { password }), - } satisfies MessageInitShape; + } satisfies MessageInitShape; const onLoginError = (message: string, extra?: () => void) => { - updateStatus(App.StatusEnum.DISCONNECTED, message); + updateStatus(StatusEnum.DISCONNECTED, message); extra?.(); WebClient.instance.response.session.loginFailed(); disconnect(); }; - WebClient.instance.protobuf.sendSessionCommand(Data.Command_Login_ext, create(Data.Command_LoginSchema, loginConfig), { - responseExt: Data.Response_Login_ext, + WebClient.instance.protobuf.sendSessionCommand(Command_Login_ext, create(Command_LoginSchema, loginConfig), { + responseExt: Response_Login_ext, onSuccess: (resp) => { const { buddyList, ignoreList, userInfo } = resp; WebClient.instance.response.session.updateBuddyList(buddyList); WebClient.instance.response.session.updateIgnoreList(ignoreList); WebClient.instance.response.session.updateUser(userInfo); - WebClient.instance.response.session.loginSuccessful({ hashedPassword: loginConfig.hashedPassword }); + WebClient.instance.response.session.loginSuccessful({ ...resp, hashedPassword: loginConfig.hashedPassword }); listUsers(); listRooms(); - updateStatus(App.StatusEnum.LOGGED_IN, 'Logged in.'); + updateStatus(StatusEnum.LOGGED_IN, 'Logged in.'); }, onResponseCode: { - [Data.Response_ResponseCode.RespClientUpdateRequired]: () => + [Response_ResponseCode.RespClientUpdateRequired]: () => onLoginError('Login failed: missing features'), - [Data.Response_ResponseCode.RespWrongPassword]: () => + [Response_ResponseCode.RespWrongPassword]: () => onLoginError('Login failed: incorrect username or password'), - [Data.Response_ResponseCode.RespUsernameInvalid]: () => + [Response_ResponseCode.RespUsernameInvalid]: () => onLoginError('Login failed: incorrect username or password'), - [Data.Response_ResponseCode.RespWouldOverwriteOldSession]: () => + [Response_ResponseCode.RespWouldOverwriteOldSession]: () => onLoginError('Login failed: duplicated user session'), - [Data.Response_ResponseCode.RespUserIsBanned]: () => + [Response_ResponseCode.RespUserIsBanned]: () => onLoginError('Login failed: banned user'), - [Data.Response_ResponseCode.RespRegistrationRequired]: () => + [Response_ResponseCode.RespRegistrationRequired]: () => onLoginError('Login failed: registration required'), - [Data.Response_ResponseCode.RespClientIdRequired]: () => + [Response_ResponseCode.RespClientIdRequired]: () => onLoginError('Login failed: missing client ID'), - [Data.Response_ResponseCode.RespContextError]: () => + [Response_ResponseCode.RespContextError]: () => onLoginError('Login failed: server error'), - [Data.Response_ResponseCode.RespAccountNotActivated]: () => + [Response_ResponseCode.RespAccountNotActivated]: (raw) => onLoginError('Login failed: account not activated', () => { WebClient.instance.response.session.accountAwaitingActivation({ + ...raw, host: options.host, port: options.port, userName: options.userName, diff --git a/webclient/src/websocket/commands/session/message.ts b/webclient/src/websocket/commands/session/message.ts index 3396a3947..94afefc46 100644 --- a/webclient/src/websocket/commands/session/message.ts +++ b/webclient/src/websocket/commands/session/message.ts @@ -1,7 +1,7 @@ import { create } from '@bufbuild/protobuf'; import { WebClient } from '../../WebClient'; -import { Data } from '@app/types'; +import { Command_Message_ext, Command_MessageSchema } from '@app/generated'; export function message(userName: string, message: string): void { - WebClient.instance.protobuf.sendSessionCommand(Data.Command_Message_ext, create(Data.Command_MessageSchema, { userName, message })); + WebClient.instance.protobuf.sendSessionCommand(Command_Message_ext, create(Command_MessageSchema, { userName, message })); } diff --git a/webclient/src/websocket/commands/session/ping.ts b/webclient/src/websocket/commands/session/ping.ts index f60687225..fab3c9272 100644 --- a/webclient/src/websocket/commands/session/ping.ts +++ b/webclient/src/websocket/commands/session/ping.ts @@ -1,9 +1,9 @@ import { create } from '@bufbuild/protobuf'; import { WebClient } from '../../WebClient'; -import { Data } from '@app/types'; +import { Command_Ping_ext, Command_PingSchema } from '@app/generated'; export function ping(pingReceived: () => void): void { - WebClient.instance.protobuf.sendSessionCommand(Data.Command_Ping_ext, create(Data.Command_PingSchema), { + WebClient.instance.protobuf.sendSessionCommand(Command_Ping_ext, create(Command_PingSchema), { onResponse: () => pingReceived(), }); } diff --git a/webclient/src/websocket/commands/session/register.ts b/webclient/src/websocket/commands/session/register.ts index 5b35a1207..ec0b5ecfa 100644 --- a/webclient/src/websocket/commands/session/register.ts +++ b/webclient/src/websocket/commands/session/register.ts @@ -1,18 +1,24 @@ -import { App, Enriched, Data } from '@app/types'; - import { create, getExtension } from '@bufbuild/protobuf'; import type { MessageInitShape } from '@bufbuild/protobuf'; +import { + Command_Register_ext, + Command_RegisterSchema, + Response_Register_ext, + Response_ResponseCode, + type RegisterParams, +} from '@app/generated'; + +import { StatusEnum } from '../../interfaces/StatusEnum'; import { CLIENT_CONFIG } from '../../config'; import { WebClient } from '../../WebClient'; - +import type { ConnectTarget } from '../../interfaces/WebClientConfig'; import { hashPassword } from '../../utils'; - import { login, disconnect, updateStatus } from './'; -export function register(options: Omit, password?: string, passwordSalt?: string): void { +export function register(options: ConnectTarget & RegisterParams, password?: string, passwordSalt?: string): void { const { userName, email, country, realName } = options; - const params: MessageInitShape = { + const params: MessageInitShape = { ...CLIENT_CONFIG, userName, email, @@ -25,53 +31,53 @@ export function register(options: Omit void) => { action(); - updateStatus(App.StatusEnum.DISCONNECTED, 'Registration failed'); + updateStatus(StatusEnum.DISCONNECTED, 'Registration failed'); disconnect(); }; - WebClient.instance.protobuf.sendSessionCommand(Data.Command_Register_ext, create(Data.Command_RegisterSchema, params), { + WebClient.instance.protobuf.sendSessionCommand(Command_Register_ext, create(Command_RegisterSchema, params), { onResponseCode: { - [Data.Response_ResponseCode.RespRegistrationAccepted]: () => { + [Response_ResponseCode.RespRegistrationAccepted]: () => { login({ host: options.host, port: options.port, userName: options.userName, - reason: App.WebSocketConnectReason.LOGIN, }, password, passwordSalt); WebClient.instance.response.session.registrationSuccess(); }, - [Data.Response_ResponseCode.RespRegistrationAcceptedNeedsActivation]: () => { - updateStatus(App.StatusEnum.DISCONNECTED, 'Registration accepted, awaiting activation'); + [Response_ResponseCode.RespRegistrationAcceptedNeedsActivation]: (raw) => { + updateStatus(StatusEnum.DISCONNECTED, 'Registration accepted, awaiting activation'); WebClient.instance.response.session.accountAwaitingActivation({ + ...raw, host: options.host, port: options.port, userName: options.userName, }); disconnect(); }, - [Data.Response_ResponseCode.RespUserAlreadyExists]: () => onRegistrationError( + [Response_ResponseCode.RespUserAlreadyExists]: () => onRegistrationError( () => WebClient.instance.response.session.registrationUserNameError('Username is taken') ), - [Data.Response_ResponseCode.RespUsernameInvalid]: () => onRegistrationError( + [Response_ResponseCode.RespUsernameInvalid]: () => onRegistrationError( () => WebClient.instance.response.session.registrationUserNameError('Invalid username') ), - [Data.Response_ResponseCode.RespPasswordTooShort]: () => onRegistrationError( + [Response_ResponseCode.RespPasswordTooShort]: () => onRegistrationError( () => WebClient.instance.response.session.registrationPasswordError('Your password was too short') ), - [Data.Response_ResponseCode.RespEmailRequiredToRegister]: () => onRegistrationError( + [Response_ResponseCode.RespEmailRequiredToRegister]: () => onRegistrationError( () => WebClient.instance.response.session.registrationRequiresEmail() ), - [Data.Response_ResponseCode.RespEmailBlackListed]: () => onRegistrationError( + [Response_ResponseCode.RespEmailBlackListed]: () => onRegistrationError( () => WebClient.instance.response.session.registrationEmailError('This email provider has been blocked') ), - [Data.Response_ResponseCode.RespTooManyRequests]: () => onRegistrationError( + [Response_ResponseCode.RespTooManyRequests]: () => onRegistrationError( () => WebClient.instance.response.session.registrationEmailError('Max accounts reached for this email') ), - [Data.Response_ResponseCode.RespRegistrationDisabled]: () => onRegistrationError( + [Response_ResponseCode.RespRegistrationDisabled]: () => onRegistrationError( () => WebClient.instance.response.session.registrationFailed('Registration is currently disabled') ), - [Data.Response_ResponseCode.RespUserIsBanned]: (raw) => { - const register = getExtension(raw, Data.Response_Register_ext); + [Response_ResponseCode.RespUserIsBanned]: (raw) => { + const register = getExtension(raw, Response_Register_ext); onRegistrationError( () => WebClient.instance.response.session.registrationFailed(register.deniedReasonStr, Number(register.deniedEndTime)) ); diff --git a/webclient/src/websocket/commands/session/removeFromList.ts b/webclient/src/websocket/commands/session/removeFromList.ts index 16e56adf8..82d971b78 100644 --- a/webclient/src/websocket/commands/session/removeFromList.ts +++ b/webclient/src/websocket/commands/session/removeFromList.ts @@ -1,7 +1,7 @@ import { create } from '@bufbuild/protobuf'; import { WebClient } from '../../WebClient'; -import { Data } from '@app/types'; +import { Command_RemoveFromList_ext, Command_RemoveFromListSchema } from '@app/generated'; export function removeFromBuddyList(userName: string): void { removeFromList('buddy', userName); @@ -13,8 +13,8 @@ export function removeFromIgnoreList(userName: string): void { export function removeFromList(list: string, userName: string): void { WebClient.instance.protobuf.sendSessionCommand( - Data.Command_RemoveFromList_ext, - create(Data.Command_RemoveFromListSchema, { list, userName }), + Command_RemoveFromList_ext, + create(Command_RemoveFromListSchema, { list, userName }), { onSuccess: () => { WebClient.instance.response.session.removeFromList(list, userName); diff --git a/webclient/src/websocket/commands/session/replayDeleteMatch.ts b/webclient/src/websocket/commands/session/replayDeleteMatch.ts index 059efabda..1da4fe7d1 100644 --- a/webclient/src/websocket/commands/session/replayDeleteMatch.ts +++ b/webclient/src/websocket/commands/session/replayDeleteMatch.ts @@ -1,12 +1,12 @@ import { create } from '@bufbuild/protobuf'; import { WebClient } from '../../WebClient'; -import { Data } from '@app/types'; +import { Command_ReplayDeleteMatch_ext, Command_ReplayDeleteMatchSchema } from '@app/generated'; export function replayDeleteMatch(gameId: number): void { WebClient.instance.protobuf.sendSessionCommand( - Data.Command_ReplayDeleteMatch_ext, - create(Data.Command_ReplayDeleteMatchSchema, { gameId }), + Command_ReplayDeleteMatch_ext, + create(Command_ReplayDeleteMatchSchema, { gameId }), { onSuccess: () => { WebClient.instance.response.session.replayDeleteMatch(gameId); diff --git a/webclient/src/websocket/commands/session/replayDownload.ts b/webclient/src/websocket/commands/session/replayDownload.ts index 950bf5891..928abfd15 100644 --- a/webclient/src/websocket/commands/session/replayDownload.ts +++ b/webclient/src/websocket/commands/session/replayDownload.ts @@ -1,14 +1,14 @@ import { create } from '@bufbuild/protobuf'; import { WebClient } from '../../WebClient'; -import { Data } from '@app/types'; +import { Command_ReplayDownload_ext, Command_ReplayDownloadSchema, Response_ReplayDownload_ext } from '@app/generated'; export function replayDownload(replayId: number): void { WebClient.instance.protobuf.sendSessionCommand( - Data.Command_ReplayDownload_ext, - create(Data.Command_ReplayDownloadSchema, { replayId }), + Command_ReplayDownload_ext, + create(Command_ReplayDownloadSchema, { replayId }), { - responseExt: Data.Response_ReplayDownload_ext, + responseExt: Response_ReplayDownload_ext, onSuccess: (response) => { WebClient.instance.response.session.replayDownloaded(replayId, response); }, diff --git a/webclient/src/websocket/commands/session/replayGetCode.ts b/webclient/src/websocket/commands/session/replayGetCode.ts index f53751f57..272307845 100644 --- a/webclient/src/websocket/commands/session/replayGetCode.ts +++ b/webclient/src/websocket/commands/session/replayGetCode.ts @@ -1,10 +1,10 @@ import { create } from '@bufbuild/protobuf'; import { WebClient } from '../../WebClient'; -import { Data } from '@app/types'; +import { Command_ReplayGetCode_ext, Command_ReplayGetCodeSchema, Response_ReplayGetCode_ext } from '@app/generated'; export function replayGetCode(gameId: number, onCodeReceived: (code: string) => void): void { - WebClient.instance.protobuf.sendSessionCommand(Data.Command_ReplayGetCode_ext, create(Data.Command_ReplayGetCodeSchema, { gameId }), { - responseExt: Data.Response_ReplayGetCode_ext, + WebClient.instance.protobuf.sendSessionCommand(Command_ReplayGetCode_ext, create(Command_ReplayGetCodeSchema, { gameId }), { + responseExt: Response_ReplayGetCode_ext, onSuccess: (response) => { onCodeReceived(response.replayCode); }, diff --git a/webclient/src/websocket/commands/session/replayList.ts b/webclient/src/websocket/commands/session/replayList.ts index 6618f41f0..60919f534 100644 --- a/webclient/src/websocket/commands/session/replayList.ts +++ b/webclient/src/websocket/commands/session/replayList.ts @@ -1,11 +1,11 @@ import { create } from '@bufbuild/protobuf'; import { WebClient } from '../../WebClient'; -import { Data } from '@app/types'; +import { Command_ReplayList_ext, Command_ReplayListSchema, Response_ReplayList_ext } from '@app/generated'; export function replayList(): void { - WebClient.instance.protobuf.sendSessionCommand(Data.Command_ReplayList_ext, create(Data.Command_ReplayListSchema), { - responseExt: Data.Response_ReplayList_ext, + WebClient.instance.protobuf.sendSessionCommand(Command_ReplayList_ext, create(Command_ReplayListSchema), { + responseExt: Response_ReplayList_ext, onSuccess: (response) => { WebClient.instance.response.session.replayList(response.matchList); }, diff --git a/webclient/src/websocket/commands/session/replayModifyMatch.ts b/webclient/src/websocket/commands/session/replayModifyMatch.ts index 3803d04bd..3d4e4af53 100644 --- a/webclient/src/websocket/commands/session/replayModifyMatch.ts +++ b/webclient/src/websocket/commands/session/replayModifyMatch.ts @@ -1,12 +1,12 @@ import { create } from '@bufbuild/protobuf'; import { WebClient } from '../../WebClient'; -import { Data } from '@app/types'; +import { Command_ReplayModifyMatch_ext, Command_ReplayModifyMatchSchema } from '@app/generated'; export function replayModifyMatch(gameId: number, doNotHide: boolean): void { WebClient.instance.protobuf.sendSessionCommand( - Data.Command_ReplayModifyMatch_ext, - create(Data.Command_ReplayModifyMatchSchema, { gameId, doNotHide }), + Command_ReplayModifyMatch_ext, + create(Command_ReplayModifyMatchSchema, { gameId, doNotHide }), { onSuccess: () => { WebClient.instance.response.session.replayModifyMatch(gameId, doNotHide); diff --git a/webclient/src/websocket/commands/session/replaySubmitCode.ts b/webclient/src/websocket/commands/session/replaySubmitCode.ts index dec72113f..aa0e403e0 100644 --- a/webclient/src/websocket/commands/session/replaySubmitCode.ts +++ b/webclient/src/websocket/commands/session/replaySubmitCode.ts @@ -1,6 +1,6 @@ import { create } from '@bufbuild/protobuf'; import { WebClient } from '../../WebClient'; -import { Data } from '@app/types'; +import { Command_ReplaySubmitCode_ext, Command_ReplaySubmitCodeSchema } from '@app/generated'; export function replaySubmitCode( replayCode: string, @@ -8,8 +8,8 @@ export function replaySubmitCode( onError?: (responseCode: number) => void, ): void { WebClient.instance.protobuf.sendSessionCommand( - Data.Command_ReplaySubmitCode_ext, - create(Data.Command_ReplaySubmitCodeSchema, { replayCode }), + Command_ReplaySubmitCode_ext, + create(Command_ReplaySubmitCodeSchema, { replayCode }), { onSuccess, onError, diff --git a/webclient/src/websocket/commands/session/requestPasswordSalt.ts b/webclient/src/websocket/commands/session/requestPasswordSalt.ts index 8c7843c82..73fed0aec 100644 --- a/webclient/src/websocket/commands/session/requestPasswordSalt.ts +++ b/webclient/src/websocket/commands/session/requestPasswordSalt.ts @@ -1,66 +1,41 @@ -import { App, Enriched, Data } from '@app/types'; - import { create } from '@bufbuild/protobuf'; +import { + Command_RequestPasswordSalt_ext, + Command_RequestPasswordSaltSchema, + Response_PasswordSalt_ext, + Response_ResponseCode, + type RequestPasswordSaltParams, +} from '@app/generated'; + +import { StatusEnum } from '../../interfaces/StatusEnum'; import { CLIENT_CONFIG } from '../../config'; import { WebClient } from '../../WebClient'; +import type { ConnectTarget } from '../../interfaces/WebClientConfig'; +import { updateStatus } from './'; -import { - activate, - disconnect, - login, - forgotPasswordReset, - updateStatus -} from './'; - -type PasswordSaltOptions = - | Omit - | Omit - | Omit; - -export function requestPasswordSalt(options: PasswordSaltOptions, password?: string, newPassword?: string): void { +export function requestPasswordSalt( + options: ConnectTarget & RequestPasswordSaltParams, + onSaltReceived: (passwordSalt: string) => void, + onFailure: () => void, +): void { const { userName } = options; - const onFailure = () => { - switch (options.reason) { - case App.WebSocketConnectReason.ACTIVATE_ACCOUNT: - WebClient.instance.response.session.accountActivationFailed(); - break; - case App.WebSocketConnectReason.PASSWORD_RESET: - WebClient.instance.response.session.resetPasswordFailed(); - break; - default: - WebClient.instance.response.session.loginFailed(); - } - disconnect(); - }; - - WebClient.instance.protobuf.sendSessionCommand(Data.Command_RequestPasswordSalt_ext, create(Data.Command_RequestPasswordSaltSchema, { + WebClient.instance.protobuf.sendSessionCommand(Command_RequestPasswordSalt_ext, create(Command_RequestPasswordSaltSchema, { ...CLIENT_CONFIG, userName, }), { - responseExt: Data.Response_PasswordSalt_ext, + responseExt: Response_PasswordSalt_ext, onSuccess: (resp) => { - const passwordSalt = resp?.passwordSalt; - - switch (options.reason) { - case App.WebSocketConnectReason.ACTIVATE_ACCOUNT: - activate(options, password, passwordSalt); - break; - case App.WebSocketConnectReason.PASSWORD_RESET: - forgotPasswordReset(options, newPassword, passwordSalt); - break; - default: - login(options, password, passwordSalt); - } + onSaltReceived(resp?.passwordSalt); }, onResponseCode: { - [Data.Response_ResponseCode.RespRegistrationRequired]: () => { - updateStatus(App.StatusEnum.DISCONNECTED, 'Login failed: registration required'); + [Response_ResponseCode.RespRegistrationRequired]: () => { + updateStatus(StatusEnum.DISCONNECTED, 'Login failed: registration required'); onFailure(); }, }, onError: () => { - updateStatus(App.StatusEnum.DISCONNECTED, 'Login failed: Unknown Reason'); + updateStatus(StatusEnum.DISCONNECTED, 'Login failed: Unknown Reason'); onFailure(); }, }); diff --git a/webclient/src/websocket/commands/session/sessionCommands-complex.spec.ts b/webclient/src/websocket/commands/session/sessionCommands-complex.spec.ts index 09f4876c9..7f0b9e2c6 100644 --- a/webclient/src/websocket/commands/session/sessionCommands-complex.spec.ts +++ b/webclient/src/websocket/commands/session/sessionCommands-complex.spec.ts @@ -1,10 +1,7 @@ // Tests for complex session commands that call WebClient directly // or have multiple branching callbacks. -vi.mock('../../WebClient', async () => { - const { makeWebClientMock } = await import('../../__mocks__/sessionCommandMocks'); - return { WebClient: { instance: makeWebClientMock() } }; -}); +vi.mock('../../WebClient'); vi.mock('../../utils', async () => { const { makeUtilsMock } = await import('../../__mocks__/sessionCommandMocks'); @@ -21,12 +18,29 @@ import { Mock } from 'vitest'; import { makeCallbackHelpers } from '../../__mocks__/callbackHelpers'; import { WebClient } from '../../WebClient'; import * as SessionIndexMocks from './'; -import { App, Enriched, Data } from '@app/types'; +import { App, Enriched } from '@app/types'; +import { StatusEnum } from '../../interfaces/StatusEnum'; +import { + Command_Activate_ext, + Command_ForgotPasswordChallenge_ext, + Command_ForgotPasswordRequest_ext, + Command_ForgotPasswordReset_ext, + Command_Login_ext, + Command_Register_ext, + Command_RequestPasswordSalt_ext, + Response_ForgotPasswordRequest_ext, + Response_Login_ext, + Response_PasswordSalt_ext, + Response_Register_ext, + Response_RegisterSchema, + Response_ResponseCode, + ResponseSchema, +} from '@app/generated'; import { hashPassword, generateSalt, passwordSaltSupported } from '../../utils'; import { create, setExtension } from '@bufbuild/protobuf'; -import { connect } from './connect'; +import { connect, testConnect } from './connect'; import { updateStatus } from './updateStatus'; import { login } from './login'; import { register } from './register'; @@ -36,7 +50,6 @@ import { forgotPasswordRequest } from './forgotPasswordRequest'; import { forgotPasswordReset } from './forgotPasswordReset'; import { requestPasswordSalt } from './requestPasswordSalt'; - const { invokeOnSuccess, invokeResponseCode, invokeOnError } = makeCallbackHelpers( WebClient.instance.protobuf.sendSessionCommand as Mock, 2 @@ -88,13 +101,7 @@ const makeForgotResetOpts = (): Enriched.PasswordResetConnectOptions => ({ newPassword: 'newpw', reason: App.WebSocketConnectReason.PASSWORD_RESET, }); -const makeSaltOpts = ( - reason: App.WebSocketConnectReason, - extras: Record = {} -) => ({ ...baseTransport, userName: 'alice', reason, ...extras } as - | Enriched.LoginConnectOptions - | Enriched.ActivateConnectOptions - | Enriched.PasswordResetConnectOptions); + beforeEach(() => { (hashPassword as Mock).mockReturnValue('hashed_pw'); @@ -107,47 +114,17 @@ beforeEach(() => { // ---------------------------------------------------------------- describe('connect', () => { - it('calls updateStatus CONNECTING for LOGIN reason', () => { - connect({ host: 'h', port: '1', userName: 'u', reason: App.WebSocketConnectReason.LOGIN }); - expect(SessionIndexMocks.updateStatus).toHaveBeenCalledWith(App.StatusEnum.CONNECTING, 'Connecting...'); - expect(WebClient.instance.connect).toHaveBeenCalled(); + it('calls WebClient.instance.connect with the target', () => { + connect({ host: 'h', port: '1' }); + expect(WebClient.instance.connect).toHaveBeenCalledWith({ host: 'h', port: '1' }); }); +}); - it('calls updateStatus CONNECTING for REGISTER reason', () => { - connect(makeRegisterOpts({ userName: 'u', realName: 'U' })); - expect(SessionIndexMocks.updateStatus).toHaveBeenCalledWith(App.StatusEnum.CONNECTING, 'Connecting...'); - }); +describe('testConnect', () => { - it('calls updateStatus CONNECTING for ACTIVATE_ACCOUNT reason', () => { - connect({ host: 'h', port: '1', userName: 'u', token: 'tok', reason: App.WebSocketConnectReason.ACTIVATE_ACCOUNT }); - expect(SessionIndexMocks.updateStatus).toHaveBeenCalledWith(App.StatusEnum.CONNECTING, 'Connecting...'); - }); - - it('calls updateStatus CONNECTING for PASSWORD_RESET_REQUEST reason', () => { - connect({ host: 'h', port: '1', userName: 'u', reason: App.WebSocketConnectReason.PASSWORD_RESET_REQUEST }); - expect(SessionIndexMocks.updateStatus).toHaveBeenCalledWith(App.StatusEnum.CONNECTING, 'Connecting...'); - }); - - it('calls updateStatus CONNECTING for PASSWORD_RESET_CHALLENGE reason', () => { - connect({ host: 'h', port: '1', userName: 'u', email: 'a@b.com', reason: App.WebSocketConnectReason.PASSWORD_RESET_CHALLENGE }); - expect(SessionIndexMocks.updateStatus).toHaveBeenCalledWith(App.StatusEnum.CONNECTING, 'Connecting...'); - }); - - it('calls updateStatus CONNECTING for PASSWORD_RESET reason', () => { - connect({ host: 'h', port: '1', userName: 'u', token: 'tok', newPassword: 'newpw', reason: App.WebSocketConnectReason.PASSWORD_RESET }); - expect(SessionIndexMocks.updateStatus).toHaveBeenCalledWith(App.StatusEnum.CONNECTING, 'Connecting...'); - }); - - it('calls testConnect for TEST_CONNECTION reason', () => { - connect({ host: 'h', port: '1', reason: App.WebSocketConnectReason.TEST_CONNECTION }); - expect(WebClient.instance.testConnect).toHaveBeenCalled(); - expect(WebClient.instance.connect).not.toHaveBeenCalled(); - }); - - it('calls updateStatus DISCONNECTED for unknown reason', () => { - const bogus = { host: 'h', port: '1', reason: 999 as App.WebSocketConnectReason }; - connect(bogus as Enriched.WebSocketConnectOptions); - expect(SessionIndexMocks.updateStatus).toHaveBeenCalledWith(App.StatusEnum.DISCONNECTED, expect.stringContaining('Unknown')); + it('calls WebClient.instance.testConnect with the target', () => { + testConnect({ host: 'h', port: '1' }); + expect(WebClient.instance.testConnect).toHaveBeenCalledWith({ host: 'h', port: '1' }); }); }); @@ -157,9 +134,9 @@ describe('connect', () => { describe('updateStatus', () => { it('calls WebClient.instance.response.session.updateStatus and WebClient.instance.updateStatus', () => { - updateStatus(App.StatusEnum.CONNECTED, 'OK'); - expect(WebClient.instance.response.session.updateStatus).toHaveBeenCalledWith(App.StatusEnum.CONNECTED, 'OK'); - expect(WebClient.instance.updateStatus).toHaveBeenCalledWith(App.StatusEnum.CONNECTED); + updateStatus(StatusEnum.CONNECTED, 'OK'); + expect(WebClient.instance.response.session.updateStatus).toHaveBeenCalledWith(StatusEnum.CONNECTED, 'OK'); + expect(WebClient.instance.updateStatus).toHaveBeenCalledWith(StatusEnum.CONNECTED); }); }); @@ -171,27 +148,27 @@ describe('login', () => { it('sends Command_Login with plain password when no salt', () => { login(makeLoginOpts(), 'pw'); expect(WebClient.instance.protobuf.sendSessionCommand).toHaveBeenCalledWith( - Data.Command_Login_ext, + Command_Login_ext, expect.objectContaining({ password: 'pw' }), - expect.objectContaining({ responseExt: Data.Response_Login_ext }) + expect.objectContaining({ responseExt: Response_Login_ext }) ); }); it('sends Command_Login with hashedPassword when salt is given', () => { login(makeLoginOpts(), 'pw', 'salt'); expect(WebClient.instance.protobuf.sendSessionCommand).toHaveBeenCalledWith( - Data.Command_Login_ext, + Command_Login_ext, expect.objectContaining({ hashedPassword: 'hashed_pw' }), - expect.objectContaining({ responseExt: Data.Response_Login_ext }) + expect.objectContaining({ responseExt: Response_Login_ext }) ); }); it('uses options.hashedPassword if provided', () => { login(makeLoginOpts({ hashedPassword: 'pre_hashed' }), 'pw', 'salt'); expect(WebClient.instance.protobuf.sendSessionCommand).toHaveBeenCalledWith( - Data.Command_Login_ext, + Command_Login_ext, expect.objectContaining({ hashedPassword: 'pre_hashed' }), - expect.objectContaining({ responseExt: Data.Response_Login_ext }) + expect.objectContaining({ responseExt: Response_Login_ext }) ); }); @@ -205,7 +182,7 @@ describe('login', () => { expect(WebClient.instance.response.session.loginSuccessful).toHaveBeenCalled(); expect(SessionIndexMocks.listUsers).toHaveBeenCalled(); expect(SessionIndexMocks.listRooms).toHaveBeenCalled(); - expect(SessionIndexMocks.updateStatus).toHaveBeenCalledWith(App.StatusEnum.LOGGED_IN, 'Logged in.'); + expect(SessionIndexMocks.updateStatus).toHaveBeenCalledWith(StatusEnum.LOGGED_IN, 'Logged in.'); }); it('onSuccess does NOT pass plaintext password to loginSuccessful', () => { @@ -226,56 +203,56 @@ describe('login', () => { it('onResponseCode RespClientUpdateRequired calls onLoginError', () => { login(makeLoginOpts(), 'pw'); - invokeResponseCode(Data.Response_ResponseCode.RespClientUpdateRequired); + invokeResponseCode(Response_ResponseCode.RespClientUpdateRequired); expect(WebClient.instance.response.session.loginFailed).toHaveBeenCalled(); expect(SessionIndexMocks.disconnect).toHaveBeenCalled(); }); it('onResponseCode RespWrongPassword', () => { login(makeLoginOpts(), 'pw'); - invokeResponseCode(Data.Response_ResponseCode.RespWrongPassword); + invokeResponseCode(Response_ResponseCode.RespWrongPassword); expect(WebClient.instance.response.session.loginFailed).toHaveBeenCalled(); }); it('onResponseCode RespUsernameInvalid', () => { login(makeLoginOpts(), 'pw'); - invokeResponseCode(Data.Response_ResponseCode.RespUsernameInvalid); + invokeResponseCode(Response_ResponseCode.RespUsernameInvalid); expect(WebClient.instance.response.session.loginFailed).toHaveBeenCalled(); }); it('onResponseCode RespWouldOverwriteOldSession', () => { login(makeLoginOpts(), 'pw'); - invokeResponseCode(Data.Response_ResponseCode.RespWouldOverwriteOldSession); + invokeResponseCode(Response_ResponseCode.RespWouldOverwriteOldSession); expect(WebClient.instance.response.session.loginFailed).toHaveBeenCalled(); }); it('onResponseCode RespUserIsBanned', () => { login(makeLoginOpts(), 'pw'); - invokeResponseCode(Data.Response_ResponseCode.RespUserIsBanned); + invokeResponseCode(Response_ResponseCode.RespUserIsBanned); expect(WebClient.instance.response.session.loginFailed).toHaveBeenCalled(); }); it('onResponseCode RespRegistrationRequired', () => { login(makeLoginOpts(), 'pw'); - invokeResponseCode(Data.Response_ResponseCode.RespRegistrationRequired); + invokeResponseCode(Response_ResponseCode.RespRegistrationRequired); expect(WebClient.instance.response.session.loginFailed).toHaveBeenCalled(); }); it('onResponseCode RespClientIdRequired', () => { login(makeLoginOpts(), 'pw'); - invokeResponseCode(Data.Response_ResponseCode.RespClientIdRequired); + invokeResponseCode(Response_ResponseCode.RespClientIdRequired); expect(WebClient.instance.response.session.loginFailed).toHaveBeenCalled(); }); it('onResponseCode RespContextError', () => { login(makeLoginOpts(), 'pw'); - invokeResponseCode(Data.Response_ResponseCode.RespContextError); + invokeResponseCode(Response_ResponseCode.RespContextError); expect(WebClient.instance.response.session.loginFailed).toHaveBeenCalled(); }); it('onResponseCode RespAccountNotActivated calls accountAwaitingActivation without password in options', () => { login(makeLoginOpts({ password: 'leaked' }), 'pw'); - invokeResponseCode(Data.Response_ResponseCode.RespAccountNotActivated); + invokeResponseCode(Response_ResponseCode.RespAccountNotActivated); expect(WebClient.instance.response.session.accountAwaitingActivation).toHaveBeenCalledWith( expect.not.objectContaining({ password: expect.anything() }) ); @@ -297,7 +274,7 @@ describe('register', () => { it('sends Command_Register with plain password when no salt', () => { register(makeRegisterOpts(), 'pw'); expect(WebClient.instance.protobuf.sendSessionCommand).toHaveBeenCalledWith( - Data.Command_Register_ext, + Command_Register_ext, expect.objectContaining({ password: 'pw' }), expect.any(Object) ); @@ -306,7 +283,7 @@ describe('register', () => { it('uses hashedPassword when salt is provided', () => { register(makeRegisterOpts(), 'pw', 'salt'); expect(WebClient.instance.protobuf.sendSessionCommand).toHaveBeenCalledWith( - Data.Command_Register_ext, + Command_Register_ext, expect.objectContaining({ hashedPassword: 'hashed_pw' }), expect.any(Object) ); @@ -314,21 +291,21 @@ describe('register', () => { it('RespRegistrationAccepted calls login without salt and registrationSuccess', () => { register(makeRegisterOpts(), 'pw'); - invokeResponseCode(Data.Response_ResponseCode.RespRegistrationAccepted); + invokeResponseCode(Response_ResponseCode.RespRegistrationAccepted); expect(SessionIndexMocks.login).toHaveBeenCalledWith(expect.any(Object), 'pw', undefined); expect(WebClient.instance.response.session.registrationSuccess).toHaveBeenCalled(); }); it('RespRegistrationAccepted forwards salt to login', () => { register(makeRegisterOpts(), 'pw', 'mySalt'); - invokeResponseCode(Data.Response_ResponseCode.RespRegistrationAccepted); + invokeResponseCode(Response_ResponseCode.RespRegistrationAccepted); expect(SessionIndexMocks.login).toHaveBeenCalledWith(expect.any(Object), 'pw', 'mySalt'); expect(WebClient.instance.response.session.registrationSuccess).toHaveBeenCalled(); }); it('RespRegistrationAcceptedNeedsActivation calls accountAwaitingActivation without password in options', () => { register(makeRegisterOpts({ password: 'leaked' }), 'pw'); - invokeResponseCode(Data.Response_ResponseCode.RespRegistrationAcceptedNeedsActivation); + invokeResponseCode(Response_ResponseCode.RespRegistrationAcceptedNeedsActivation); expect(WebClient.instance.response.session.accountAwaitingActivation).toHaveBeenCalledWith( expect.not.objectContaining({ password: expect.anything() }) ); @@ -337,53 +314,53 @@ describe('register', () => { it('RespUserAlreadyExists calls registrationUserNameError', () => { register(makeRegisterOpts(), 'pw'); - invokeResponseCode(Data.Response_ResponseCode.RespUserAlreadyExists); + invokeResponseCode(Response_ResponseCode.RespUserAlreadyExists); expect(WebClient.instance.response.session.registrationUserNameError).toHaveBeenCalled(); }); it('RespUsernameInvalid calls registrationUserNameError', () => { register(makeRegisterOpts(), 'pw'); - invokeResponseCode(Data.Response_ResponseCode.RespUsernameInvalid); + invokeResponseCode(Response_ResponseCode.RespUsernameInvalid); expect(WebClient.instance.response.session.registrationUserNameError).toHaveBeenCalled(); }); it('RespPasswordTooShort calls registrationPasswordError', () => { register(makeRegisterOpts(), 'pw'); - invokeResponseCode(Data.Response_ResponseCode.RespPasswordTooShort); + invokeResponseCode(Response_ResponseCode.RespPasswordTooShort); expect(WebClient.instance.response.session.registrationPasswordError).toHaveBeenCalled(); }); it('RespEmailRequiredToRegister calls registrationRequiresEmail', () => { register(makeRegisterOpts(), 'pw'); - invokeResponseCode(Data.Response_ResponseCode.RespEmailRequiredToRegister); + invokeResponseCode(Response_ResponseCode.RespEmailRequiredToRegister); expect(WebClient.instance.response.session.registrationRequiresEmail).toHaveBeenCalled(); }); it('RespEmailBlackListed calls registrationEmailError', () => { register(makeRegisterOpts(), 'pw'); - invokeResponseCode(Data.Response_ResponseCode.RespEmailBlackListed); + invokeResponseCode(Response_ResponseCode.RespEmailBlackListed); expect(WebClient.instance.response.session.registrationEmailError).toHaveBeenCalled(); }); it('RespTooManyRequests calls registrationEmailError', () => { register(makeRegisterOpts(), 'pw'); - invokeResponseCode(Data.Response_ResponseCode.RespTooManyRequests); + invokeResponseCode(Response_ResponseCode.RespTooManyRequests); expect(WebClient.instance.response.session.registrationEmailError).toHaveBeenCalled(); }); it('RespRegistrationDisabled calls registrationFailed', () => { register(makeRegisterOpts(), 'pw'); - invokeResponseCode(Data.Response_ResponseCode.RespRegistrationDisabled); + invokeResponseCode(Response_ResponseCode.RespRegistrationDisabled); expect(WebClient.instance.response.session.registrationFailed).toHaveBeenCalled(); }); it('RespUserIsBanned calls registrationFailed with deniedReasonStr and deniedEndTime', () => { register(makeRegisterOpts(), 'pw'); - const raw = create(Data.ResponseSchema, { responseCode: Data.Response_ResponseCode.RespUserIsBanned }); - setExtension(raw, Data.Response_Register_ext, create(Data.Response_RegisterSchema, { + const raw = create(ResponseSchema, { responseCode: Response_ResponseCode.RespUserIsBanned }); + setExtension(raw, Response_Register_ext, create(Response_RegisterSchema, { deniedReasonStr: 'bad user', deniedEndTime: 9999n, })); - invokeResponseCode(Data.Response_ResponseCode.RespUserIsBanned, raw); + invokeResponseCode(Response_ResponseCode.RespUserIsBanned, raw); expect(WebClient.instance.response.session.registrationFailed).toHaveBeenCalledWith('bad user', 9999); }); @@ -402,12 +379,12 @@ describe('activate', () => { it('sends Command_Activate with userName and token, not password', () => { activate(makeActivateOpts(), 'pw'); expect(WebClient.instance.protobuf.sendSessionCommand).toHaveBeenCalledWith( - Data.Command_Activate_ext, + Command_Activate_ext, expect.objectContaining({ userName: 'alice', token: 'tok' }), expect.any(Object) ); expect(WebClient.instance.protobuf.sendSessionCommand).toHaveBeenCalledWith( - Data.Command_Activate_ext, + Command_Activate_ext, expect.not.objectContaining({ password: expect.anything() }), expect.any(Object) ); @@ -415,7 +392,7 @@ describe('activate', () => { it('RespActivationAccepted calls accountActivationSuccess and forwards password+salt to login', () => { activate(makeActivateOpts(), 'pw', 'salt'); - invokeResponseCode(Data.Response_ResponseCode.RespActivationAccepted); + invokeResponseCode(Response_ResponseCode.RespActivationAccepted); expect(WebClient.instance.response.session.accountActivationSuccess).toHaveBeenCalled(); expect(SessionIndexMocks.login).toHaveBeenCalledWith(expect.any(Object), 'pw', 'salt'); }); @@ -436,7 +413,7 @@ describe('forgotPasswordChallenge', () => { it('sends Command_ForgotPasswordChallenge', () => { forgotPasswordChallenge(makeForgotChallengeOpts()); expect(WebClient.instance.protobuf.sendSessionCommand).toHaveBeenCalledWith( - Data.Command_ForgotPasswordChallenge_ext, expect.any(Object), expect.any(Object) + Command_ForgotPasswordChallenge_ext, expect.any(Object), expect.any(Object) ); }); @@ -463,9 +440,9 @@ describe('forgotPasswordRequest', () => { it('sends Command_ForgotPasswordRequest', () => { forgotPasswordRequest(makeForgotRequestOpts()); expect(WebClient.instance.protobuf.sendSessionCommand).toHaveBeenCalledWith( - Data.Command_ForgotPasswordRequest_ext, + Command_ForgotPasswordRequest_ext, expect.any(Object), - expect.objectContaining({ responseExt: Data.Response_ForgotPasswordRequest_ext }) + expect.objectContaining({ responseExt: Response_ForgotPasswordRequest_ext }) ); }); @@ -501,7 +478,7 @@ describe('forgotPasswordReset', () => { it('sends Command_ForgotPasswordReset with plain newPassword when no salt', () => { forgotPasswordReset(makeForgotResetOpts(), 'newpw'); expect(WebClient.instance.protobuf.sendSessionCommand).toHaveBeenCalledWith( - Data.Command_ForgotPasswordReset_ext, + Command_ForgotPasswordReset_ext, expect.objectContaining({ newPassword: 'newpw' }), expect.any(Object) ); @@ -510,7 +487,7 @@ describe('forgotPasswordReset', () => { it('sends hashed new password when salt provided', () => { forgotPasswordReset(makeForgotResetOpts(), 'newpw', 'salt'); expect(WebClient.instance.protobuf.sendSessionCommand).toHaveBeenCalledWith( - Data.Command_ForgotPasswordReset_ext, + Command_ForgotPasswordReset_ext, expect.objectContaining({ hashedNewPassword: 'hashed_pw' }), expect.any(Object) ); @@ -537,66 +514,40 @@ describe('forgotPasswordReset', () => { describe('requestPasswordSalt', () => { it('sends Command_RequestPasswordSalt', () => { - requestPasswordSalt(makeSaltOpts(App.WebSocketConnectReason.LOGIN), 'pw'); + const onSaltReceived = vi.fn(); + const onFailure = vi.fn(); + requestPasswordSalt({ host: 'h', port: '1', userName: 'alice' }, onSaltReceived, onFailure); expect(WebClient.instance.protobuf.sendSessionCommand).toHaveBeenCalledWith( - Data.Command_RequestPasswordSalt_ext, + Command_RequestPasswordSalt_ext, expect.any(Object), - expect.objectContaining({ responseExt: Data.Response_PasswordSalt_ext }) + expect.objectContaining({ responseExt: Response_PasswordSalt_ext }) ); }); - it('onSuccess with LOGIN reason forwards password+salt to login', () => { - requestPasswordSalt(makeSaltOpts(App.WebSocketConnectReason.LOGIN), 'pw'); + it('onSuccess calls onSaltReceived with the salt', () => { + const onSaltReceived = vi.fn(); + const onFailure = vi.fn(); + requestPasswordSalt({ host: 'h', port: '1', userName: 'alice' }, onSaltReceived, onFailure); const resp = { passwordSalt: 'salt123' }; invokeOnSuccess(resp, { responseCode: 0 }); - expect(SessionIndexMocks.login).toHaveBeenCalledWith(expect.any(Object), 'pw', 'salt123'); + expect(onSaltReceived).toHaveBeenCalledWith('salt123'); }); - it('onSuccess with ACTIVATE_ACCOUNT reason forwards password+salt to activate', () => { - requestPasswordSalt(makeSaltOpts(App.WebSocketConnectReason.ACTIVATE_ACCOUNT, { token: 'tok' }), 'pw'); - const resp = { passwordSalt: 'salt123' }; - invokeOnSuccess(resp, { responseCode: 0 }); - expect(SessionIndexMocks.activate).toHaveBeenCalledWith(expect.any(Object), 'pw', 'salt123'); + it('onResponseCode RespRegistrationRequired calls updateStatus and onFailure', () => { + const onSaltReceived = vi.fn(); + const onFailure = vi.fn(); + requestPasswordSalt({ host: 'h', port: '1', userName: 'alice' }, onSaltReceived, onFailure); + invokeResponseCode(Response_ResponseCode.RespRegistrationRequired); + expect(SessionIndexMocks.updateStatus).toHaveBeenCalledWith(StatusEnum.DISCONNECTED, expect.any(String)); + expect(onFailure).toHaveBeenCalled(); }); - it('onSuccess with PASSWORD_RESET reason forwards newPassword+salt to forgotPasswordReset', () => { - requestPasswordSalt( - makeSaltOpts(App.WebSocketConnectReason.PASSWORD_RESET, { token: 'tok', newPassword: 'newpw' }), - undefined, - 'newpw' - ); - const resp = { passwordSalt: 'salt123' }; - invokeOnSuccess(resp, { responseCode: 0 }); - expect(SessionIndexMocks.forgotPasswordReset).toHaveBeenCalledWith(expect.any(Object), 'newpw', 'salt123'); - }); - - it('onResponseCode RespRegistrationRequired calls updateStatus and disconnect', () => { - requestPasswordSalt(makeSaltOpts(App.WebSocketConnectReason.LOGIN), 'pw'); - invokeResponseCode(Data.Response_ResponseCode.RespRegistrationRequired); - expect(SessionIndexMocks.updateStatus).toHaveBeenCalledWith(App.StatusEnum.DISCONNECTED, expect.any(String)); - expect(SessionIndexMocks.disconnect).toHaveBeenCalled(); - }); - - it('onResponseCode RespRegistrationRequired with ACTIVATE_ACCOUNT calls accountActivationFailed', () => { - requestPasswordSalt(makeSaltOpts(App.WebSocketConnectReason.ACTIVATE_ACCOUNT, { token: 'tok' }), 'pw'); - invokeResponseCode(Data.Response_ResponseCode.RespRegistrationRequired); - expect(WebClient.instance.response.session.accountActivationFailed).toHaveBeenCalled(); - }); - - it('onError calls updateStatus DISCONNECTED and disconnect', () => { - requestPasswordSalt(makeSaltOpts(App.WebSocketConnectReason.LOGIN), 'pw'); + it('onError calls updateStatus DISCONNECTED and onFailure', () => { + const onSaltReceived = vi.fn(); + const onFailure = vi.fn(); + requestPasswordSalt({ host: 'h', port: '1', userName: 'alice' }, onSaltReceived, onFailure); invokeOnError(); expect(SessionIndexMocks.updateStatus).toHaveBeenCalled(); - expect(SessionIndexMocks.disconnect).toHaveBeenCalled(); - }); - - it('onError with PASSWORD_RESET reason calls resetPasswordFailed', () => { - requestPasswordSalt( - makeSaltOpts(App.WebSocketConnectReason.PASSWORD_RESET, { token: 'tok', newPassword: 'newpw' }), - undefined, - 'newpw' - ); - invokeOnError(); - expect(WebClient.instance.response.session.resetPasswordFailed).toHaveBeenCalled(); + expect(onFailure).toHaveBeenCalled(); }); }); diff --git a/webclient/src/websocket/commands/session/sessionCommands-simple.spec.ts b/webclient/src/websocket/commands/session/sessionCommands-simple.spec.ts index 97b2acdab..18c8be728 100644 --- a/webclient/src/websocket/commands/session/sessionCommands-simple.spec.ts +++ b/webclient/src/websocket/commands/session/sessionCommands-simple.spec.ts @@ -1,9 +1,6 @@ // Shared mock setup for session command tests -vi.mock('../../WebClient', async () => { - const { makeWebClientMock } = await import('../../__mocks__/sessionCommandMocks'); - return { WebClient: { instance: makeWebClientMock() } }; -}); +vi.mock('../../WebClient'); vi.mock('../../utils', async () => { const { makeUtilsMock } = await import('../../__mocks__/sessionCommandMocks'); @@ -47,8 +44,42 @@ import { addToList, addToBuddyList, addToIgnoreList } from './addToList'; import { removeFromList, removeFromBuddyList, removeFromIgnoreList } from './removeFromList'; import { replayGetCode } from './replayGetCode'; import { replaySubmitCode } from './replaySubmitCode'; -import { Data } from '@app/types'; - +import { + Command_AccountEdit_ext, + Command_AccountImage_ext, + Command_AccountPassword_ext, + Command_AddToList_ext, + Command_DeckDel_ext, + Command_DeckDelDir_ext, + Command_DeckDownload_ext, + Command_DeckList_ext, + Command_DeckNewDir_ext, + Command_DeckUpload_ext, + Command_GetGamesOfUser_ext, + Command_GetUserInfo_ext, + Command_JoinRoom_ext, + Command_ListRooms_ext, + Command_ListUsers_ext, + Command_Message_ext, + Command_Ping_ext, + Command_RemoveFromList_ext, + Command_ReplayDeleteMatch_ext, + Command_ReplayDownload_ext, + Command_ReplayGetCode_ext, + Command_ReplayList_ext, + Command_ReplayModifyMatch_ext, + Command_ReplaySubmitCode_ext, + Response_DeckDownload_ext, + Response_DeckList_ext, + Response_DeckUpload_ext, + Response_GetGamesOfUser_ext, + Response_GetUserInfo_ext, + Response_JoinRoom_ext, + Response_ListUsers_ext, + Response_ReplayDownload_ext, + Response_ReplayGetCode_ext, + Response_ReplayList_ext, +} from '@app/generated'; const { invokeOnSuccess, invokeCallback } = makeCallbackHelpers( WebClient.instance.protobuf.sendSessionCommand as Mock, @@ -67,7 +98,7 @@ describe('accountEdit', () => { it('sends Command_AccountEdit with correct params', () => { accountEdit('pw', 'Alice', 'a@b.com', 'US'); expect(WebClient.instance.protobuf.sendSessionCommand).toHaveBeenCalledWith( - Data.Command_AccountEdit_ext, + Command_AccountEdit_ext, expect.objectContaining({ passwordCheck: 'pw', realName: 'Alice', email: 'a@b.com', country: 'US' }), expect.any(Object) ); @@ -85,7 +116,7 @@ describe('accountImage', () => { const img = new Uint8Array([1, 2]); accountImage(img); expect(WebClient.instance.protobuf.sendSessionCommand).toHaveBeenCalledWith( - Data.Command_AccountImage_ext, expect.objectContaining({ image: img }), expect.any(Object) + Command_AccountImage_ext, expect.objectContaining({ image: img }), expect.any(Object) ); }); @@ -101,7 +132,7 @@ describe('accountPassword', () => { it('sends Command_AccountPassword', () => { accountPassword('old', 'new', 'hashed'); expect(WebClient.instance.protobuf.sendSessionCommand).toHaveBeenCalledWith( - Data.Command_AccountPassword_ext, + Command_AccountPassword_ext, expect.objectContaining({ oldPassword: 'old', newPassword: 'new', hashedNewPassword: 'hashed' }), expect.any(Object) ); @@ -118,7 +149,7 @@ describe('deckDel', () => { it('sends Command_DeckDel', () => { deckDel(42); expect(WebClient.instance.protobuf.sendSessionCommand).toHaveBeenCalledWith( - Data.Command_DeckDel_ext, + Command_DeckDel_ext, expect.objectContaining({ deckId: 42 }), expect.any(Object) ); @@ -135,7 +166,7 @@ describe('deckDelDir', () => { it('sends Command_DeckDelDir', () => { deckDelDir('/path'); expect(WebClient.instance.protobuf.sendSessionCommand).toHaveBeenCalledWith( - Data.Command_DeckDelDir_ext, expect.objectContaining({ path: '/path' }), expect.any(Object) + Command_DeckDelDir_ext, expect.objectContaining({ path: '/path' }), expect.any(Object) ); }); @@ -150,9 +181,9 @@ describe('deckList', () => { it('sends Command_DeckList', () => { deckList(); expect(WebClient.instance.protobuf.sendSessionCommand).toHaveBeenCalledWith( - Data.Command_DeckList_ext, + Command_DeckList_ext, expect.any(Object), - expect.objectContaining({ responseExt: Data.Response_DeckList_ext }) + expect.objectContaining({ responseExt: Response_DeckList_ext }) ); }); @@ -168,7 +199,7 @@ describe('deckNewDir', () => { it('sends Command_DeckNewDir', () => { deckNewDir('/path', 'dir'); expect(WebClient.instance.protobuf.sendSessionCommand).toHaveBeenCalledWith( - Data.Command_DeckNewDir_ext, expect.objectContaining({ path: '/path', dirName: 'dir' }), expect.any(Object) + Command_DeckNewDir_ext, expect.objectContaining({ path: '/path', dirName: 'dir' }), expect.any(Object) ); }); @@ -183,9 +214,9 @@ describe('deckUpload', () => { it('sends Command_DeckUpload', () => { deckUpload('/path', 1, 'content'); expect(WebClient.instance.protobuf.sendSessionCommand).toHaveBeenCalledWith( - Data.Command_DeckUpload_ext, + Command_DeckUpload_ext, expect.objectContaining({ path: '/path', deckId: 1, deckList: 'content' }), - expect.objectContaining({ responseExt: Data.Response_DeckUpload_ext }) + expect.objectContaining({ responseExt: Response_DeckUpload_ext }) ); }); @@ -208,9 +239,9 @@ describe('getGamesOfUser', () => { it('sends Command_GetGamesOfUser', () => { getGamesOfUser('alice'); expect(WebClient.instance.protobuf.sendSessionCommand).toHaveBeenCalledWith( - Data.Command_GetGamesOfUser_ext, + Command_GetGamesOfUser_ext, expect.any(Object), - expect.objectContaining({ responseExt: Data.Response_GetGamesOfUser_ext }) + expect.objectContaining({ responseExt: Response_GetGamesOfUser_ext }) ); }); @@ -226,9 +257,9 @@ describe('getUserInfo', () => { it('sends Command_GetUserInfo', () => { getUserInfo('alice'); expect(WebClient.instance.protobuf.sendSessionCommand).toHaveBeenCalledWith( - Data.Command_GetUserInfo_ext, + Command_GetUserInfo_ext, expect.any(Object), - expect.objectContaining({ responseExt: Data.Response_GetUserInfo_ext }) + expect.objectContaining({ responseExt: Response_GetUserInfo_ext }) ); }); @@ -244,9 +275,9 @@ describe('joinRoom', () => { it('sends Command_JoinRoom', () => { joinRoom(5); expect(WebClient.instance.protobuf.sendSessionCommand).toHaveBeenCalledWith( - Data.Command_JoinRoom_ext, + Command_JoinRoom_ext, expect.any(Object), - expect.objectContaining({ responseExt: Data.Response_JoinRoom_ext }) + expect.objectContaining({ responseExt: Response_JoinRoom_ext }) ); }); @@ -261,7 +292,7 @@ describe('joinRoom', () => { describe('listRooms (command)', () => { it('sends Command_ListRooms', () => { listRooms(); - expect(WebClient.instance.protobuf.sendSessionCommand).toHaveBeenCalledWith(Data.Command_ListRooms_ext, expect.any(Object)); + expect(WebClient.instance.protobuf.sendSessionCommand).toHaveBeenCalledWith(Command_ListRooms_ext, expect.any(Object)); }); }); @@ -269,9 +300,9 @@ describe('listUsers', () => { it('sends Command_ListUsers', () => { listUsers(); expect(WebClient.instance.protobuf.sendSessionCommand).toHaveBeenCalledWith( - Data.Command_ListUsers_ext, + Command_ListUsers_ext, expect.any(Object), - expect.objectContaining({ responseExt: Data.Response_ListUsers_ext }) + expect.objectContaining({ responseExt: Response_ListUsers_ext }) ); }); @@ -287,7 +318,7 @@ describe('message', () => { it('sends Command_Message', () => { message('bob', 'hi'); expect(WebClient.instance.protobuf.sendSessionCommand).toHaveBeenCalledWith( - Data.Command_Message_ext, expect.objectContaining({ userName: 'bob', message: 'hi' }) + Command_Message_ext, expect.objectContaining({ userName: 'bob', message: 'hi' }) ); }); @@ -298,7 +329,7 @@ describe('ping', () => { const pingReceived = vi.fn(); ping(pingReceived); expect(WebClient.instance.protobuf.sendSessionCommand).toHaveBeenCalledWith( - Data.Command_Ping_ext, expect.any(Object), expect.any(Object) + Command_Ping_ext, expect.any(Object), expect.any(Object) ); }); @@ -314,7 +345,7 @@ describe('replayDeleteMatch', () => { it('sends Command_ReplayDeleteMatch', () => { replayDeleteMatch(7); expect(WebClient.instance.protobuf.sendSessionCommand).toHaveBeenCalledWith( - Data.Command_ReplayDeleteMatch_ext, + Command_ReplayDeleteMatch_ext, expect.objectContaining({ gameId: 7 }), expect.any(Object) ); @@ -331,9 +362,9 @@ describe('replayList', () => { it('sends Command_ReplayList', () => { replayList(); expect(WebClient.instance.protobuf.sendSessionCommand).toHaveBeenCalledWith( - Data.Command_ReplayList_ext, + Command_ReplayList_ext, expect.any(Object), - expect.objectContaining({ responseExt: Data.Response_ReplayList_ext }) + expect.objectContaining({ responseExt: Response_ReplayList_ext }) ); }); @@ -349,7 +380,7 @@ describe('replayModifyMatch', () => { it('sends Command_ReplayModifyMatch', () => { replayModifyMatch(7, true); expect(WebClient.instance.protobuf.sendSessionCommand).toHaveBeenCalledWith( - Data.Command_ReplayModifyMatch_ext, expect.objectContaining({ gameId: 7, doNotHide: true }), expect.any(Object) + Command_ReplayModifyMatch_ext, expect.objectContaining({ gameId: 7, doNotHide: true }), expect.any(Object) ); }); @@ -364,7 +395,7 @@ describe('addToList / addToBuddyList / addToIgnoreList', () => { it('addToBuddyList sends Command_AddToList with list=buddy', () => { addToBuddyList('alice'); expect(WebClient.instance.protobuf.sendSessionCommand).toHaveBeenCalledWith( - Data.Command_AddToList_ext, + Command_AddToList_ext, expect.objectContaining({ list: 'buddy' }), expect.any(Object) ); @@ -373,7 +404,7 @@ describe('addToList / addToBuddyList / addToIgnoreList', () => { it('addToIgnoreList sends Command_AddToList with list=ignore', () => { addToIgnoreList('bob'); expect(WebClient.instance.protobuf.sendSessionCommand).toHaveBeenCalledWith( - Data.Command_AddToList_ext, + Command_AddToList_ext, expect.objectContaining({ list: 'ignore' }), expect.any(Object) ); @@ -390,7 +421,7 @@ describe('removeFromList / removeFromBuddyList / removeFromIgnoreList', () => { it('removeFromBuddyList sends Command_RemoveFromList with list=buddy', () => { removeFromBuddyList('alice'); expect(WebClient.instance.protobuf.sendSessionCommand).toHaveBeenCalledWith( - Data.Command_RemoveFromList_ext, + Command_RemoveFromList_ext, expect.objectContaining({ list: 'buddy' }), expect.any(Object) ); @@ -399,7 +430,7 @@ describe('removeFromList / removeFromBuddyList / removeFromIgnoreList', () => { it('removeFromIgnoreList sends Command_RemoveFromList with list=ignore', () => { removeFromIgnoreList('bob'); expect(WebClient.instance.protobuf.sendSessionCommand).toHaveBeenCalledWith( - Data.Command_RemoveFromList_ext, + Command_RemoveFromList_ext, expect.objectContaining({ list: 'ignore' }), expect.any(Object) ); @@ -416,9 +447,9 @@ describe('replayGetCode', () => { it('sends Command_ReplayGetCode with gameId and responseExt', () => { replayGetCode(42, vi.fn()); expect(WebClient.instance.protobuf.sendSessionCommand).toHaveBeenCalledWith( - Data.Command_ReplayGetCode_ext, + Command_ReplayGetCode_ext, expect.any(Object), - expect.objectContaining({ responseExt: Data.Response_ReplayGetCode_ext }) + expect.objectContaining({ responseExt: Response_ReplayGetCode_ext }) ); }); @@ -434,7 +465,7 @@ describe('replaySubmitCode', () => { it('sends Command_ReplaySubmitCode with replayCode', () => { replaySubmitCode('42-abc123'); expect(WebClient.instance.protobuf.sendSessionCommand).toHaveBeenCalledWith( - Data.Command_ReplaySubmitCode_ext, expect.objectContaining({ replayCode: '42-abc123' }), expect.any(Object) + Command_ReplaySubmitCode_ext, expect.objectContaining({ replayCode: '42-abc123' }), expect.any(Object) ); }); @@ -457,9 +488,9 @@ describe('deckDownload', () => { it('sends Command_DeckDownload', () => { deckDownload(42); expect(WebClient.instance.protobuf.sendSessionCommand).toHaveBeenCalledWith( - Data.Command_DeckDownload_ext, + Command_DeckDownload_ext, expect.objectContaining({ deckId: 42 }), - expect.objectContaining({ responseExt: Data.Response_DeckDownload_ext }) + expect.objectContaining({ responseExt: Response_DeckDownload_ext }) ); }); @@ -475,9 +506,9 @@ describe('replayDownload', () => { it('sends Command_ReplayDownload', () => { replayDownload(99); expect(WebClient.instance.protobuf.sendSessionCommand).toHaveBeenCalledWith( - Data.Command_ReplayDownload_ext, + Command_ReplayDownload_ext, expect.objectContaining({ replayId: 99 }), - expect.objectContaining({ responseExt: Data.Response_ReplayDownload_ext }) + expect.objectContaining({ responseExt: Response_ReplayDownload_ext }) ); }); diff --git a/webclient/src/websocket/commands/session/updateStatus.ts b/webclient/src/websocket/commands/session/updateStatus.ts index 1289c5ed8..52cb9ccbc 100644 --- a/webclient/src/websocket/commands/session/updateStatus.ts +++ b/webclient/src/websocket/commands/session/updateStatus.ts @@ -1,7 +1,7 @@ -import { App } from '@app/types'; +import { StatusEnum } from '../../interfaces/StatusEnum'; import { WebClient } from '../../WebClient'; -export function updateStatus(status: App.StatusEnum, description: string): void { - WebClient.instance.response.session.updateStatus(status, description); +export function updateStatus(status: StatusEnum, description: string): void { + WebClient.instance.response.session.updateStatus(status, description); WebClient.instance.updateStatus(status); } diff --git a/webclient/src/websocket/config.ts b/webclient/src/websocket/config.ts index 9f3d12643..f27988561 100644 --- a/webclient/src/websocket/config.ts +++ b/webclient/src/websocket/config.ts @@ -1,3 +1,5 @@ +export const PROTOCOL_VERSION = 14; + export const CLIENT_CONFIG = { clientid: 'webatrice', clientver: 'webclient-1.0 (2019-10-31)', @@ -19,8 +21,6 @@ export const CLIENT_CONFIG = { ] } as const; -export const PROTOCOL_VERSION = 14; - export const CLIENT_OPTIONS = { autojoinrooms: true, keepalive: 5000 diff --git a/webclient/src/websocket/events/game/attachCard.ts b/webclient/src/websocket/events/game/attachCard.ts index c88082378..a3671cb05 100644 --- a/webclient/src/websocket/events/game/attachCard.ts +++ b/webclient/src/websocket/events/game/attachCard.ts @@ -1,6 +1,7 @@ -import type { Data, Enriched } from '@app/types'; +import type { Event_AttachCard } from '@app/generated'; +import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; import { WebClient } from '../../WebClient'; -export function attachCard(data: Data.Event_AttachCard, meta: Enriched.GameEventMeta): void { +export function attachCard(data: Event_AttachCard, meta: GameEventMeta): void { WebClient.instance.response.game.cardAttached(meta.gameId, meta.playerId, data); } diff --git a/webclient/src/websocket/events/game/changeZoneProperties.ts b/webclient/src/websocket/events/game/changeZoneProperties.ts index 65b062274..88d7f95f1 100644 --- a/webclient/src/websocket/events/game/changeZoneProperties.ts +++ b/webclient/src/websocket/events/game/changeZoneProperties.ts @@ -1,6 +1,7 @@ -import type { Data, Enriched } from '@app/types'; +import type { Event_ChangeZoneProperties } from '@app/generated'; +import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; import { WebClient } from '../../WebClient'; -export function changeZoneProperties(data: Data.Event_ChangeZoneProperties, meta: Enriched.GameEventMeta): void { +export function changeZoneProperties(data: Event_ChangeZoneProperties, meta: GameEventMeta): void { WebClient.instance.response.game.zonePropertiesChanged(meta.gameId, meta.playerId, data); } diff --git a/webclient/src/websocket/events/game/createArrow.ts b/webclient/src/websocket/events/game/createArrow.ts index 6ae8946aa..3f39fa064 100644 --- a/webclient/src/websocket/events/game/createArrow.ts +++ b/webclient/src/websocket/events/game/createArrow.ts @@ -1,6 +1,7 @@ -import type { Data, Enriched } from '@app/types'; +import type { Event_CreateArrow } from '@app/generated'; +import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; import { WebClient } from '../../WebClient'; -export function createArrow(data: Data.Event_CreateArrow, meta: Enriched.GameEventMeta): void { +export function createArrow(data: Event_CreateArrow, meta: GameEventMeta): void { WebClient.instance.response.game.arrowCreated(meta.gameId, meta.playerId, data); } diff --git a/webclient/src/websocket/events/game/createCounter.ts b/webclient/src/websocket/events/game/createCounter.ts index 8c1873339..db9ca6086 100644 --- a/webclient/src/websocket/events/game/createCounter.ts +++ b/webclient/src/websocket/events/game/createCounter.ts @@ -1,6 +1,7 @@ -import type { Data, Enriched } from '@app/types'; +import type { Event_CreateCounter } from '@app/generated'; +import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; import { WebClient } from '../../WebClient'; -export function createCounter(data: Data.Event_CreateCounter, meta: Enriched.GameEventMeta): void { +export function createCounter(data: Event_CreateCounter, meta: GameEventMeta): void { WebClient.instance.response.game.counterCreated(meta.gameId, meta.playerId, data); } diff --git a/webclient/src/websocket/events/game/createToken.ts b/webclient/src/websocket/events/game/createToken.ts index 6396f5b82..1542d4418 100644 --- a/webclient/src/websocket/events/game/createToken.ts +++ b/webclient/src/websocket/events/game/createToken.ts @@ -1,6 +1,7 @@ -import type { Data, Enriched } from '@app/types'; +import type { Event_CreateToken } from '@app/generated'; +import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; import { WebClient } from '../../WebClient'; -export function createToken(data: Data.Event_CreateToken, meta: Enriched.GameEventMeta): void { +export function createToken(data: Event_CreateToken, meta: GameEventMeta): void { WebClient.instance.response.game.tokenCreated(meta.gameId, meta.playerId, data); } diff --git a/webclient/src/websocket/events/game/delCounter.ts b/webclient/src/websocket/events/game/delCounter.ts index ac3d40345..1a128b0ad 100644 --- a/webclient/src/websocket/events/game/delCounter.ts +++ b/webclient/src/websocket/events/game/delCounter.ts @@ -1,6 +1,7 @@ -import type { Data, Enriched } from '@app/types'; +import type { Event_DelCounter } from '@app/generated'; +import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; import { WebClient } from '../../WebClient'; -export function delCounter(data: Data.Event_DelCounter, meta: Enriched.GameEventMeta): void { +export function delCounter(data: Event_DelCounter, meta: GameEventMeta): void { WebClient.instance.response.game.counterDeleted(meta.gameId, meta.playerId, data); } diff --git a/webclient/src/websocket/events/game/deleteArrow.ts b/webclient/src/websocket/events/game/deleteArrow.ts index 52e0d6bf0..df39f5d47 100644 --- a/webclient/src/websocket/events/game/deleteArrow.ts +++ b/webclient/src/websocket/events/game/deleteArrow.ts @@ -1,6 +1,7 @@ -import type { Data, Enriched } from '@app/types'; +import type { Event_DeleteArrow } from '@app/generated'; +import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; import { WebClient } from '../../WebClient'; -export function deleteArrow(data: Data.Event_DeleteArrow, meta: Enriched.GameEventMeta): void { +export function deleteArrow(data: Event_DeleteArrow, meta: GameEventMeta): void { WebClient.instance.response.game.arrowDeleted(meta.gameId, meta.playerId, data); } diff --git a/webclient/src/websocket/events/game/destroyCard.ts b/webclient/src/websocket/events/game/destroyCard.ts index 70d59582c..65cb87d38 100644 --- a/webclient/src/websocket/events/game/destroyCard.ts +++ b/webclient/src/websocket/events/game/destroyCard.ts @@ -1,6 +1,7 @@ -import type { Data, Enriched } from '@app/types'; +import type { Event_DestroyCard } from '@app/generated'; +import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; import { WebClient } from '../../WebClient'; -export function destroyCard(data: Data.Event_DestroyCard, meta: Enriched.GameEventMeta): void { +export function destroyCard(data: Event_DestroyCard, meta: GameEventMeta): void { WebClient.instance.response.game.cardDestroyed(meta.gameId, meta.playerId, data); } diff --git a/webclient/src/websocket/events/game/drawCards.ts b/webclient/src/websocket/events/game/drawCards.ts index 2932ebe3b..c9fed3078 100644 --- a/webclient/src/websocket/events/game/drawCards.ts +++ b/webclient/src/websocket/events/game/drawCards.ts @@ -1,6 +1,7 @@ -import type { Data, Enriched } from '@app/types'; +import type { Event_DrawCards } from '@app/generated'; +import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; import { WebClient } from '../../WebClient'; -export function drawCards(data: Data.Event_DrawCards, meta: Enriched.GameEventMeta): void { +export function drawCards(data: Event_DrawCards, meta: GameEventMeta): void { WebClient.instance.response.game.cardsDrawn(meta.gameId, meta.playerId, data); } diff --git a/webclient/src/websocket/events/game/dumpZone.ts b/webclient/src/websocket/events/game/dumpZone.ts index c528aba2e..8302877ef 100644 --- a/webclient/src/websocket/events/game/dumpZone.ts +++ b/webclient/src/websocket/events/game/dumpZone.ts @@ -1,6 +1,7 @@ -import type { Data, Enriched } from '@app/types'; +import type { Event_DumpZone } from '@app/generated'; +import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; import { WebClient } from '../../WebClient'; -export function dumpZone(data: Data.Event_DumpZone, meta: Enriched.GameEventMeta): void { +export function dumpZone(data: Event_DumpZone, meta: GameEventMeta): void { WebClient.instance.response.game.zoneDumped(meta.gameId, meta.playerId, data); } diff --git a/webclient/src/websocket/events/game/flipCard.ts b/webclient/src/websocket/events/game/flipCard.ts index 9930580a0..1c4e74cd3 100644 --- a/webclient/src/websocket/events/game/flipCard.ts +++ b/webclient/src/websocket/events/game/flipCard.ts @@ -1,6 +1,7 @@ -import type { Data, Enriched } from '@app/types'; +import type { Event_FlipCard } from '@app/generated'; +import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; import { WebClient } from '../../WebClient'; -export function flipCard(data: Data.Event_FlipCard, meta: Enriched.GameEventMeta): void { +export function flipCard(data: Event_FlipCard, meta: GameEventMeta): void { WebClient.instance.response.game.cardFlipped(meta.gameId, meta.playerId, data); } diff --git a/webclient/src/websocket/events/game/gameClosed.ts b/webclient/src/websocket/events/game/gameClosed.ts index 9a4c0924d..73fc445e2 100644 --- a/webclient/src/websocket/events/game/gameClosed.ts +++ b/webclient/src/websocket/events/game/gameClosed.ts @@ -1,6 +1,6 @@ -import { Enriched } from '@app/types'; +import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; import { WebClient } from '../../WebClient'; -export function gameClosed(_data: {}, meta: Enriched.GameEventMeta): void { +export function gameClosed(_data: {}, meta: GameEventMeta): void { WebClient.instance.response.game.gameClosed(meta.gameId); } diff --git a/webclient/src/websocket/events/game/gameEvents.spec.ts b/webclient/src/websocket/events/game/gameEvents.spec.ts index d5537fb23..1276f1724 100644 --- a/webclient/src/websocket/events/game/gameEvents.spec.ts +++ b/webclient/src/websocket/events/game/gameEvents.spec.ts @@ -1,47 +1,32 @@ -vi.mock('../../WebClient', () => ({ - WebClient: { - instance: { - response: { - game: { - gameStateChanged: vi.fn(), - playerJoined: vi.fn(), - playerLeft: vi.fn(), - playerPropertiesChanged: vi.fn(), - gameClosed: vi.fn(), - gameHostChanged: vi.fn(), - kicked: vi.fn(), - gameSay: vi.fn(), - cardMoved: vi.fn(), - cardFlipped: vi.fn(), - cardDestroyed: vi.fn(), - cardAttached: vi.fn(), - tokenCreated: vi.fn(), - cardAttrChanged: vi.fn(), - cardCounterChanged: vi.fn(), - arrowCreated: vi.fn(), - arrowDeleted: vi.fn(), - counterCreated: vi.fn(), - counterSet: vi.fn(), - counterDeleted: vi.fn(), - cardsDrawn: vi.fn(), - cardsRevealed: vi.fn(), - zoneShuffled: vi.fn(), - dieRolled: vi.fn(), - activePlayerSet: vi.fn(), - activePhaseSet: vi.fn(), - turnReversed: vi.fn(), - zoneDumped: vi.fn(), - zonePropertiesChanged: vi.fn(), - }, - }, - }, - }, -})); - +vi.mock('../../WebClient'); import { create } from '@bufbuild/protobuf'; -import { Data } from '@app/types'; +import { + Event_AttachCardSchema, + Event_ChangeZonePropertiesSchema, + Event_CreateArrowSchema, + Event_CreateCounterSchema, + Event_CreateTokenSchema, + Event_DelCounterSchema, + Event_DeleteArrowSchema, + Event_DestroyCardSchema, + Event_DrawCardsSchema, + Event_DumpZoneSchema, + Event_FlipCardSchema, + Event_GameSaySchema, + Event_GameStateChangedSchema, + Event_MoveCardSchema, + Event_RevealCardsSchema, + Event_ReverseTurnSchema, + Event_RollDieSchema, + Event_SetActivePhaseSchema, + Event_SetActivePlayerSchema, + Event_SetCardAttrSchema, + Event_SetCardCounterSchema, + Event_SetCounterSchema, + Event_ShuffleSchema, + ServerInfo_PlayerPropertiesSchema, +} from '@app/generated'; import { WebClient } from '../../WebClient'; - import { attachCard } from './attachCard'; import { changeZoneProperties } from './changeZoneProperties'; import { createArrow } from './createArrow'; @@ -76,7 +61,7 @@ const meta = { gameId: 5, playerId: 2, context: null, secondsElapsed: 0, forcedB describe('joinGame event', () => { it('delegates to WebClient.instance.response.game.playerJoined with gameId from meta', () => { - const playerProperties = create(Data.ServerInfo_PlayerPropertiesSchema, { playerId: 1 }); + const playerProperties = create(ServerInfo_PlayerPropertiesSchema, { playerId: 1 }); const data = { playerProperties }; joinGame(data, meta); expect(WebClient.instance.response.game.playerJoined).toHaveBeenCalledWith(5, playerProperties); @@ -114,7 +99,7 @@ describe('kicked event', () => { describe('gameStateChanged event', () => { it('delegates to WebClient.instance.response.game.gameStateChanged with gameId and full data', () => { - const data = create(Data.Event_GameStateChangedSchema, { playerList: [] }); + const data = create(Event_GameStateChangedSchema, { playerList: [] }); gameStateChanged(data, meta); expect(WebClient.instance.response.game.gameStateChanged).toHaveBeenCalledWith(5, data); }); @@ -122,7 +107,7 @@ describe('gameStateChanged event', () => { describe('playerPropertiesChanged event', () => { it('delegates to WebClient.instance.response.game.playerPropertiesChanged with gameId, playerId, properties', () => { - const playerProperties = create(Data.ServerInfo_PlayerPropertiesSchema, { playerId: 2 }); + const playerProperties = create(ServerInfo_PlayerPropertiesSchema, { playerId: 2 }); const data = { playerProperties }; playerPropertiesChanged(data, meta); expect(WebClient.instance.response.game.playerPropertiesChanged).toHaveBeenCalledWith(5, 2, playerProperties); @@ -131,7 +116,7 @@ describe('playerPropertiesChanged event', () => { describe('gameSay event', () => { it('delegates to WebClient.instance.response.game.gameSay with gameId, playerId, message', () => { - const data = create(Data.Event_GameSaySchema, { message: 'gg' }); + const data = create(Event_GameSaySchema, { message: 'gg' }); gameSay(data, meta); expect(WebClient.instance.response.game.gameSay).toHaveBeenCalledWith(5, 2, 'gg'); }); @@ -139,7 +124,7 @@ describe('gameSay event', () => { describe('moveCard event', () => { it('delegates to WebClient.instance.response.game.cardMoved with gameId, playerId and data', () => { - const data = create(Data.Event_MoveCardSchema, { cardId: 3 }); + const data = create(Event_MoveCardSchema, { cardId: 3 }); moveCard(data, meta); expect(WebClient.instance.response.game.cardMoved).toHaveBeenCalledWith(5, 2, data); }); @@ -147,7 +132,7 @@ describe('moveCard event', () => { describe('flipCard event', () => { it('delegates to WebClient.instance.response.game.cardFlipped with gameId, playerId and data', () => { - const data = create(Data.Event_FlipCardSchema, { cardId: 3 }); + const data = create(Event_FlipCardSchema, { cardId: 3 }); flipCard(data, meta); expect(WebClient.instance.response.game.cardFlipped).toHaveBeenCalledWith(5, 2, data); }); @@ -155,7 +140,7 @@ describe('flipCard event', () => { describe('destroyCard event', () => { it('delegates to WebClient.instance.response.game.cardDestroyed with gameId, playerId and data', () => { - const data = create(Data.Event_DestroyCardSchema, { cardId: 3 }); + const data = create(Event_DestroyCardSchema, { cardId: 3 }); destroyCard(data, meta); expect(WebClient.instance.response.game.cardDestroyed).toHaveBeenCalledWith(5, 2, data); }); @@ -163,7 +148,7 @@ describe('destroyCard event', () => { describe('attachCard event', () => { it('delegates to WebClient.instance.response.game.cardAttached with gameId, playerId and data', () => { - const data = create(Data.Event_AttachCardSchema, { cardId: 3 }); + const data = create(Event_AttachCardSchema, { cardId: 3 }); attachCard(data, meta); expect(WebClient.instance.response.game.cardAttached).toHaveBeenCalledWith(5, 2, data); }); @@ -171,7 +156,7 @@ describe('attachCard event', () => { describe('createToken event', () => { it('delegates to WebClient.instance.response.game.tokenCreated with gameId, playerId and data', () => { - const data = create(Data.Event_CreateTokenSchema, { cardId: 3 }); + const data = create(Event_CreateTokenSchema, { cardId: 3 }); createToken(data, meta); expect(WebClient.instance.response.game.tokenCreated).toHaveBeenCalledWith(5, 2, data); }); @@ -179,7 +164,7 @@ describe('createToken event', () => { describe('setCardAttr event', () => { it('delegates to WebClient.instance.response.game.cardAttrChanged with gameId, playerId and data', () => { - const data = create(Data.Event_SetCardAttrSchema, { cardId: 3 }); + const data = create(Event_SetCardAttrSchema, { cardId: 3 }); setCardAttr(data, meta); expect(WebClient.instance.response.game.cardAttrChanged).toHaveBeenCalledWith(5, 2, data); }); @@ -187,7 +172,7 @@ describe('setCardAttr event', () => { describe('setCardCounter event', () => { it('delegates to WebClient.instance.response.game.cardCounterChanged with gameId, playerId and data', () => { - const data = create(Data.Event_SetCardCounterSchema, { cardId: 3 }); + const data = create(Event_SetCardCounterSchema, { cardId: 3 }); setCardCounter(data, meta); expect(WebClient.instance.response.game.cardCounterChanged).toHaveBeenCalledWith(5, 2, data); }); @@ -195,7 +180,7 @@ describe('setCardCounter event', () => { describe('createArrow event', () => { it('delegates to WebClient.instance.response.game.arrowCreated with gameId, playerId and data', () => { - const data = create(Data.Event_CreateArrowSchema, {}); + const data = create(Event_CreateArrowSchema, {}); createArrow(data, meta); expect(WebClient.instance.response.game.arrowCreated).toHaveBeenCalledWith(5, 2, data); }); @@ -203,7 +188,7 @@ describe('createArrow event', () => { describe('deleteArrow event', () => { it('delegates to WebClient.instance.response.game.arrowDeleted with gameId, playerId and data', () => { - const data = create(Data.Event_DeleteArrowSchema, { arrowId: 9 }); + const data = create(Event_DeleteArrowSchema, { arrowId: 9 }); deleteArrow(data, meta); expect(WebClient.instance.response.game.arrowDeleted).toHaveBeenCalledWith(5, 2, data); }); @@ -211,7 +196,7 @@ describe('deleteArrow event', () => { describe('createCounter event', () => { it('delegates to WebClient.instance.response.game.counterCreated with gameId, playerId and data', () => { - const data = create(Data.Event_CreateCounterSchema, {}); + const data = create(Event_CreateCounterSchema, {}); createCounter(data, meta); expect(WebClient.instance.response.game.counterCreated).toHaveBeenCalledWith(5, 2, data); }); @@ -219,7 +204,7 @@ describe('createCounter event', () => { describe('setCounter event', () => { it('delegates to WebClient.instance.response.game.counterSet with gameId, playerId and data', () => { - const data = create(Data.Event_SetCounterSchema, { counterId: 1, value: 20 }); + const data = create(Event_SetCounterSchema, { counterId: 1, value: 20 }); setCounter(data, meta); expect(WebClient.instance.response.game.counterSet).toHaveBeenCalledWith(5, 2, data); }); @@ -227,7 +212,7 @@ describe('setCounter event', () => { describe('delCounter event', () => { it('delegates to WebClient.instance.response.game.counterDeleted with gameId, playerId and data', () => { - const data = create(Data.Event_DelCounterSchema, { counterId: 1 }); + const data = create(Event_DelCounterSchema, { counterId: 1 }); delCounter(data, meta); expect(WebClient.instance.response.game.counterDeleted).toHaveBeenCalledWith(5, 2, data); }); @@ -235,7 +220,7 @@ describe('delCounter event', () => { describe('drawCards event', () => { it('delegates to WebClient.instance.response.game.cardsDrawn with gameId, playerId and data', () => { - const data = create(Data.Event_DrawCardsSchema, { number: 2, cards: [] }); + const data = create(Event_DrawCardsSchema, { number: 2, cards: [] }); drawCards(data, meta); expect(WebClient.instance.response.game.cardsDrawn).toHaveBeenCalledWith(5, 2, data); }); @@ -243,7 +228,7 @@ describe('drawCards event', () => { describe('revealCards event', () => { it('delegates to WebClient.instance.response.game.cardsRevealed with gameId, playerId and data', () => { - const data = create(Data.Event_RevealCardsSchema, { zoneName: 'hand', cards: [] }); + const data = create(Event_RevealCardsSchema, { zoneName: 'hand', cards: [] }); revealCards(data, meta); expect(WebClient.instance.response.game.cardsRevealed).toHaveBeenCalledWith(5, 2, data); }); @@ -251,7 +236,7 @@ describe('revealCards event', () => { describe('shuffle event', () => { it('delegates to WebClient.instance.response.game.zoneShuffled with gameId, playerId and data', () => { - const data = create(Data.Event_ShuffleSchema, { zoneName: 'deck' }); + const data = create(Event_ShuffleSchema, { zoneName: 'deck' }); shuffle(data, meta); expect(WebClient.instance.response.game.zoneShuffled).toHaveBeenCalledWith(5, 2, data); }); @@ -259,7 +244,7 @@ describe('shuffle event', () => { describe('rollDie event', () => { it('delegates to WebClient.instance.response.game.dieRolled with gameId, playerId and data', () => { - const data = create(Data.Event_RollDieSchema, { die: 6, result: 4 }); + const data = create(Event_RollDieSchema, { die: 6, result: 4 }); rollDie(data, meta); expect(WebClient.instance.response.game.dieRolled).toHaveBeenCalledWith(5, 2, data); }); @@ -267,7 +252,7 @@ describe('rollDie event', () => { describe('setActivePlayer event', () => { it('delegates to WebClient.instance.response.game.activePlayerSet with gameId and activePlayerId', () => { - const data = create(Data.Event_SetActivePlayerSchema, { activePlayerId: 3 }); + const data = create(Event_SetActivePlayerSchema, { activePlayerId: 3 }); setActivePlayer(data, meta); expect(WebClient.instance.response.game.activePlayerSet).toHaveBeenCalledWith(5, 3); }); @@ -275,7 +260,7 @@ describe('setActivePlayer event', () => { describe('setActivePhase event', () => { it('delegates to WebClient.instance.response.game.activePhaseSet with gameId and phase', () => { - const data = create(Data.Event_SetActivePhaseSchema, { phase: 4 }); + const data = create(Event_SetActivePhaseSchema, { phase: 4 }); setActivePhase(data, meta); expect(WebClient.instance.response.game.activePhaseSet).toHaveBeenCalledWith(5, 4); }); @@ -283,7 +268,7 @@ describe('setActivePhase event', () => { describe('reverseTurn event', () => { it('delegates to WebClient.instance.response.game.turnReversed with gameId and reversed', () => { - const data = create(Data.Event_ReverseTurnSchema, { reversed: true }); + const data = create(Event_ReverseTurnSchema, { reversed: true }); reverseTurn(data, meta); expect(WebClient.instance.response.game.turnReversed).toHaveBeenCalledWith(5, true); }); @@ -291,7 +276,7 @@ describe('reverseTurn event', () => { describe('dumpZone event', () => { it('delegates to WebClient.instance.response.game.zoneDumped with gameId, playerId and data', () => { - const data = create(Data.Event_DumpZoneSchema, { zoneName: 'hand' }); + const data = create(Event_DumpZoneSchema, { zoneName: 'hand' }); dumpZone(data, meta); expect(WebClient.instance.response.game.zoneDumped).toHaveBeenCalledWith(5, 2, data); }); @@ -299,7 +284,7 @@ describe('dumpZone event', () => { describe('changeZoneProperties event', () => { it('delegates to WebClient.instance.response.game.zonePropertiesChanged with gameId, playerId and data', () => { - const data = create(Data.Event_ChangeZonePropertiesSchema, { zoneName: 'hand', alwaysRevealTopCard: true }); + const data = create(Event_ChangeZonePropertiesSchema, { zoneName: 'hand', alwaysRevealTopCard: true }); changeZoneProperties(data, meta); expect(WebClient.instance.response.game.zonePropertiesChanged).toHaveBeenCalledWith(5, 2, data); }); diff --git a/webclient/src/websocket/events/game/gameHostChanged.ts b/webclient/src/websocket/events/game/gameHostChanged.ts index 4c24f0373..2cc7e5064 100644 --- a/webclient/src/websocket/events/game/gameHostChanged.ts +++ b/webclient/src/websocket/events/game/gameHostChanged.ts @@ -1,10 +1,10 @@ -import { Enriched } from '@app/types'; +import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; import { WebClient } from '../../WebClient'; /** * Event_GameHostChanged carries no payload fields. * The new host is identified by GameEvent.player_id (meta.playerId). */ -export function gameHostChanged(_data: {}, meta: Enriched.GameEventMeta): void { +export function gameHostChanged(_data: {}, meta: GameEventMeta): void { WebClient.instance.response.game.gameHostChanged(meta.gameId, meta.playerId); } diff --git a/webclient/src/websocket/events/game/gameSay.ts b/webclient/src/websocket/events/game/gameSay.ts index f84c54c0f..72a585fef 100644 --- a/webclient/src/websocket/events/game/gameSay.ts +++ b/webclient/src/websocket/events/game/gameSay.ts @@ -1,6 +1,7 @@ -import type { Data, Enriched } from '@app/types'; +import type { Event_GameSay } from '@app/generated'; +import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; import { WebClient } from '../../WebClient'; -export function gameSay(data: Data.Event_GameSay, meta: Enriched.GameEventMeta): void { +export function gameSay(data: Event_GameSay, meta: GameEventMeta): void { WebClient.instance.response.game.gameSay(meta.gameId, meta.playerId, data.message); } diff --git a/webclient/src/websocket/events/game/gameStateChanged.ts b/webclient/src/websocket/events/game/gameStateChanged.ts index 533ffb3c9..ff3d53fa3 100644 --- a/webclient/src/websocket/events/game/gameStateChanged.ts +++ b/webclient/src/websocket/events/game/gameStateChanged.ts @@ -1,6 +1,7 @@ -import type { Data, Enriched } from '@app/types'; +import type { Event_GameStateChanged } from '@app/generated'; +import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; import { WebClient } from '../../WebClient'; -export function gameStateChanged(data: Data.Event_GameStateChanged, meta: Enriched.GameEventMeta): void { +export function gameStateChanged(data: Event_GameStateChanged, meta: GameEventMeta): void { WebClient.instance.response.game.gameStateChanged(meta.gameId, data); } diff --git a/webclient/src/websocket/events/game/index.ts b/webclient/src/websocket/events/game/index.ts index e9931880c..d19292e80 100644 --- a/webclient/src/websocket/events/game/index.ts +++ b/webclient/src/websocket/events/game/index.ts @@ -1,6 +1,41 @@ import type { GenExtension } from '@bufbuild/protobuf/codegenv2'; -import { Data, Enriched } from '@app/types'; +import { + type RegistryEntry, + type GameEvent, + makeEntry, + Event_Join_ext, + Event_Leave_ext, + Event_GameClosed_ext, + Event_GameHostChanged_ext, + Event_Kicked_ext, + Event_GameStateChanged_ext, + Event_PlayerPropertiesChanged_ext, + Event_GameSay_ext, + Event_CreateArrow_ext, + Event_DeleteArrow_ext, + Event_CreateCounter_ext, + Event_SetCounter_ext, + Event_DelCounter_ext, + Event_DrawCards_ext, + Event_RevealCards_ext, + Event_Shuffle_ext, + Event_RollDie_ext, + Event_MoveCard_ext, + Event_FlipCard_ext, + Event_DestroyCard_ext, + Event_AttachCard_ext, + Event_CreateToken_ext, + Event_SetCardAttr_ext, + Event_SetCardCounter_ext, + Event_SetActivePlayer_ext, + Event_SetActivePhase_ext, + Event_DumpZone_ext, + Event_ChangeZoneProperties_ext, + Event_ReverseTurn_ext, +} from '@app/generated'; + +import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; import { attachCard } from './attachCard'; import { changeZoneProperties } from './changeZoneProperties'; @@ -32,44 +67,44 @@ import { setCardCounter } from './setCardCounter'; import { setCounter } from './setCounter'; import { shuffle } from './shuffle'; -type GameRegistryEntry = Data.RegistryEntry; +type GameRegistryEntry = RegistryEntry; export type GameExtensionRegistry = GameRegistryEntry[]; function makeGameEntry( - ext: GenExtension, - handler: (value: V, meta: Enriched.GameEventMeta) => void, + ext: GenExtension, + handler: (value: V, meta: GameEventMeta) => void, ): GameRegistryEntry { - return Data.makeEntry(ext, handler); + return makeEntry(ext, handler); } export const GameEvents: GameExtensionRegistry = [ - makeGameEntry(Data.Event_Join_ext, joinGame), - makeGameEntry(Data.Event_Leave_ext, leaveGame), - makeGameEntry(Data.Event_GameClosed_ext, gameClosed), - makeGameEntry(Data.Event_GameHostChanged_ext, gameHostChanged), - makeGameEntry(Data.Event_Kicked_ext, kicked), - makeGameEntry(Data.Event_GameStateChanged_ext, gameStateChanged), - makeGameEntry(Data.Event_PlayerPropertiesChanged_ext, playerPropertiesChanged), - makeGameEntry(Data.Event_GameSay_ext, gameSay), - makeGameEntry(Data.Event_CreateArrow_ext, createArrow), - makeGameEntry(Data.Event_DeleteArrow_ext, deleteArrow), - makeGameEntry(Data.Event_CreateCounter_ext, createCounter), - makeGameEntry(Data.Event_SetCounter_ext, setCounter), - makeGameEntry(Data.Event_DelCounter_ext, delCounter), - makeGameEntry(Data.Event_DrawCards_ext, drawCards), - makeGameEntry(Data.Event_RevealCards_ext, revealCards), - makeGameEntry(Data.Event_Shuffle_ext, shuffle), - makeGameEntry(Data.Event_RollDie_ext, rollDie), - makeGameEntry(Data.Event_MoveCard_ext, moveCard), - makeGameEntry(Data.Event_FlipCard_ext, flipCard), - makeGameEntry(Data.Event_DestroyCard_ext, destroyCard), - makeGameEntry(Data.Event_AttachCard_ext, attachCard), - makeGameEntry(Data.Event_CreateToken_ext, createToken), - makeGameEntry(Data.Event_SetCardAttr_ext, setCardAttr), - makeGameEntry(Data.Event_SetCardCounter_ext, setCardCounter), - makeGameEntry(Data.Event_SetActivePlayer_ext, setActivePlayer), - makeGameEntry(Data.Event_SetActivePhase_ext, setActivePhase), - makeGameEntry(Data.Event_DumpZone_ext, dumpZone), - makeGameEntry(Data.Event_ChangeZoneProperties_ext, changeZoneProperties), - makeGameEntry(Data.Event_ReverseTurn_ext, reverseTurn), + makeGameEntry(Event_Join_ext, joinGame), + makeGameEntry(Event_Leave_ext, leaveGame), + makeGameEntry(Event_GameClosed_ext, gameClosed), + makeGameEntry(Event_GameHostChanged_ext, gameHostChanged), + makeGameEntry(Event_Kicked_ext, kicked), + makeGameEntry(Event_GameStateChanged_ext, gameStateChanged), + makeGameEntry(Event_PlayerPropertiesChanged_ext, playerPropertiesChanged), + makeGameEntry(Event_GameSay_ext, gameSay), + makeGameEntry(Event_CreateArrow_ext, createArrow), + makeGameEntry(Event_DeleteArrow_ext, deleteArrow), + makeGameEntry(Event_CreateCounter_ext, createCounter), + makeGameEntry(Event_SetCounter_ext, setCounter), + makeGameEntry(Event_DelCounter_ext, delCounter), + makeGameEntry(Event_DrawCards_ext, drawCards), + makeGameEntry(Event_RevealCards_ext, revealCards), + makeGameEntry(Event_Shuffle_ext, shuffle), + makeGameEntry(Event_RollDie_ext, rollDie), + makeGameEntry(Event_MoveCard_ext, moveCard), + makeGameEntry(Event_FlipCard_ext, flipCard), + makeGameEntry(Event_DestroyCard_ext, destroyCard), + makeGameEntry(Event_AttachCard_ext, attachCard), + makeGameEntry(Event_CreateToken_ext, createToken), + makeGameEntry(Event_SetCardAttr_ext, setCardAttr), + makeGameEntry(Event_SetCardCounter_ext, setCardCounter), + makeGameEntry(Event_SetActivePlayer_ext, setActivePlayer), + makeGameEntry(Event_SetActivePhase_ext, setActivePhase), + makeGameEntry(Event_DumpZone_ext, dumpZone), + makeGameEntry(Event_ChangeZoneProperties_ext, changeZoneProperties), + makeGameEntry(Event_ReverseTurn_ext, reverseTurn), ]; diff --git a/webclient/src/websocket/events/game/joinGame.ts b/webclient/src/websocket/events/game/joinGame.ts index 1f3b627d0..f0efd619b 100644 --- a/webclient/src/websocket/events/game/joinGame.ts +++ b/webclient/src/websocket/events/game/joinGame.ts @@ -1,7 +1,8 @@ -import type { Data, Enriched } from '@app/types'; +import type { Event_Join } from '@app/generated'; +import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; import { WebClient } from '../../WebClient'; -export function joinGame(data: Data.Event_Join, meta: Enriched.GameEventMeta): void { +export function joinGame(data: Event_Join, meta: GameEventMeta): void { WebClient.instance.response.game.playerJoined(meta.gameId, data.playerProperties); } diff --git a/webclient/src/websocket/events/game/kicked.ts b/webclient/src/websocket/events/game/kicked.ts index ca4a64d5e..f63951dcc 100644 --- a/webclient/src/websocket/events/game/kicked.ts +++ b/webclient/src/websocket/events/game/kicked.ts @@ -1,6 +1,6 @@ -import { Enriched } from '@app/types'; +import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; import { WebClient } from '../../WebClient'; -export function kicked(_data: {}, meta: Enriched.GameEventMeta): void { +export function kicked(_data: {}, meta: GameEventMeta): void { WebClient.instance.response.game.kicked(meta.gameId); } diff --git a/webclient/src/websocket/events/game/leaveGame.ts b/webclient/src/websocket/events/game/leaveGame.ts index 2354e41ec..5d2df026e 100644 --- a/webclient/src/websocket/events/game/leaveGame.ts +++ b/webclient/src/websocket/events/game/leaveGame.ts @@ -1,6 +1,6 @@ -import { Enriched } from '@app/types'; +import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; import { WebClient } from '../../WebClient'; -export function leaveGame(data: { reason: number }, meta: Enriched.GameEventMeta): void { +export function leaveGame(data: { reason: number }, meta: GameEventMeta): void { WebClient.instance.response.game.playerLeft(meta.gameId, meta.playerId, data.reason ?? 1); } diff --git a/webclient/src/websocket/events/game/moveCard.ts b/webclient/src/websocket/events/game/moveCard.ts index d2f589d2b..a553e727a 100644 --- a/webclient/src/websocket/events/game/moveCard.ts +++ b/webclient/src/websocket/events/game/moveCard.ts @@ -1,6 +1,7 @@ -import type { Data, Enriched } from '@app/types'; +import type { Event_MoveCard } from '@app/generated'; +import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; import { WebClient } from '../../WebClient'; -export function moveCard(data: Data.Event_MoveCard, meta: Enriched.GameEventMeta): void { +export function moveCard(data: Event_MoveCard, meta: GameEventMeta): void { WebClient.instance.response.game.cardMoved(meta.gameId, meta.playerId, data); } diff --git a/webclient/src/websocket/events/game/playerPropertiesChanged.ts b/webclient/src/websocket/events/game/playerPropertiesChanged.ts index 5181d9ccc..073f5a408 100644 --- a/webclient/src/websocket/events/game/playerPropertiesChanged.ts +++ b/webclient/src/websocket/events/game/playerPropertiesChanged.ts @@ -1,6 +1,7 @@ -import type { Data, Enriched } from '@app/types'; +import type { Event_PlayerPropertiesChanged } from '@app/generated'; +import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; import { WebClient } from '../../WebClient'; -export function playerPropertiesChanged(data: Data.Event_PlayerPropertiesChanged, meta: Enriched.GameEventMeta): void { +export function playerPropertiesChanged(data: Event_PlayerPropertiesChanged, meta: GameEventMeta): void { WebClient.instance.response.game.playerPropertiesChanged(meta.gameId, meta.playerId, data.playerProperties); } diff --git a/webclient/src/websocket/events/game/revealCards.ts b/webclient/src/websocket/events/game/revealCards.ts index 8e2f814f5..9eb08b82a 100644 --- a/webclient/src/websocket/events/game/revealCards.ts +++ b/webclient/src/websocket/events/game/revealCards.ts @@ -1,6 +1,7 @@ -import type { Data, Enriched } from '@app/types'; +import type { Event_RevealCards } from '@app/generated'; +import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; import { WebClient } from '../../WebClient'; -export function revealCards(data: Data.Event_RevealCards, meta: Enriched.GameEventMeta): void { +export function revealCards(data: Event_RevealCards, meta: GameEventMeta): void { WebClient.instance.response.game.cardsRevealed(meta.gameId, meta.playerId, data); } diff --git a/webclient/src/websocket/events/game/reverseTurn.ts b/webclient/src/websocket/events/game/reverseTurn.ts index 2b21074fa..ef953aba8 100644 --- a/webclient/src/websocket/events/game/reverseTurn.ts +++ b/webclient/src/websocket/events/game/reverseTurn.ts @@ -1,6 +1,7 @@ -import type { Data, Enriched } from '@app/types'; +import type { Event_ReverseTurn } from '@app/generated'; +import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; import { WebClient } from '../../WebClient'; -export function reverseTurn(data: Data.Event_ReverseTurn, meta: Enriched.GameEventMeta): void { +export function reverseTurn(data: Event_ReverseTurn, meta: GameEventMeta): void { WebClient.instance.response.game.turnReversed(meta.gameId, data.reversed); } diff --git a/webclient/src/websocket/events/game/rollDie.ts b/webclient/src/websocket/events/game/rollDie.ts index ee8bf8098..ca5365144 100644 --- a/webclient/src/websocket/events/game/rollDie.ts +++ b/webclient/src/websocket/events/game/rollDie.ts @@ -1,6 +1,7 @@ -import type { Data, Enriched } from '@app/types'; +import type { Event_RollDie } from '@app/generated'; +import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; import { WebClient } from '../../WebClient'; -export function rollDie(data: Data.Event_RollDie, meta: Enriched.GameEventMeta): void { +export function rollDie(data: Event_RollDie, meta: GameEventMeta): void { WebClient.instance.response.game.dieRolled(meta.gameId, meta.playerId, data); } diff --git a/webclient/src/websocket/events/game/setActivePhase.ts b/webclient/src/websocket/events/game/setActivePhase.ts index ad7c3ba88..134651bdb 100644 --- a/webclient/src/websocket/events/game/setActivePhase.ts +++ b/webclient/src/websocket/events/game/setActivePhase.ts @@ -1,6 +1,7 @@ -import type { Data, Enriched } from '@app/types'; +import type { Event_SetActivePhase } from '@app/generated'; +import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; import { WebClient } from '../../WebClient'; -export function setActivePhase(data: Data.Event_SetActivePhase, meta: Enriched.GameEventMeta): void { +export function setActivePhase(data: Event_SetActivePhase, meta: GameEventMeta): void { WebClient.instance.response.game.activePhaseSet(meta.gameId, data.phase); } diff --git a/webclient/src/websocket/events/game/setActivePlayer.ts b/webclient/src/websocket/events/game/setActivePlayer.ts index 569e1dadb..e84f44920 100644 --- a/webclient/src/websocket/events/game/setActivePlayer.ts +++ b/webclient/src/websocket/events/game/setActivePlayer.ts @@ -1,6 +1,7 @@ -import type { Data, Enriched } from '@app/types'; +import type { Event_SetActivePlayer } from '@app/generated'; +import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; import { WebClient } from '../../WebClient'; -export function setActivePlayer(data: Data.Event_SetActivePlayer, meta: Enriched.GameEventMeta): void { +export function setActivePlayer(data: Event_SetActivePlayer, meta: GameEventMeta): void { WebClient.instance.response.game.activePlayerSet(meta.gameId, data.activePlayerId); } diff --git a/webclient/src/websocket/events/game/setCardAttr.ts b/webclient/src/websocket/events/game/setCardAttr.ts index a3229df7f..3733dc7b1 100644 --- a/webclient/src/websocket/events/game/setCardAttr.ts +++ b/webclient/src/websocket/events/game/setCardAttr.ts @@ -1,6 +1,7 @@ -import type { Data, Enriched } from '@app/types'; +import type { Event_SetCardAttr } from '@app/generated'; +import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; import { WebClient } from '../../WebClient'; -export function setCardAttr(data: Data.Event_SetCardAttr, meta: Enriched.GameEventMeta): void { +export function setCardAttr(data: Event_SetCardAttr, meta: GameEventMeta): void { WebClient.instance.response.game.cardAttrChanged(meta.gameId, meta.playerId, data); } diff --git a/webclient/src/websocket/events/game/setCardCounter.ts b/webclient/src/websocket/events/game/setCardCounter.ts index 622e1af54..c96d50ac6 100644 --- a/webclient/src/websocket/events/game/setCardCounter.ts +++ b/webclient/src/websocket/events/game/setCardCounter.ts @@ -1,6 +1,7 @@ -import type { Data, Enriched } from '@app/types'; +import type { Event_SetCardCounter } from '@app/generated'; +import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; import { WebClient } from '../../WebClient'; -export function setCardCounter(data: Data.Event_SetCardCounter, meta: Enriched.GameEventMeta): void { +export function setCardCounter(data: Event_SetCardCounter, meta: GameEventMeta): void { WebClient.instance.response.game.cardCounterChanged(meta.gameId, meta.playerId, data); } diff --git a/webclient/src/websocket/events/game/setCounter.ts b/webclient/src/websocket/events/game/setCounter.ts index e62f695ba..310fb9e28 100644 --- a/webclient/src/websocket/events/game/setCounter.ts +++ b/webclient/src/websocket/events/game/setCounter.ts @@ -1,6 +1,7 @@ -import type { Data, Enriched } from '@app/types'; +import type { Event_SetCounter } from '@app/generated'; +import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; import { WebClient } from '../../WebClient'; -export function setCounter(data: Data.Event_SetCounter, meta: Enriched.GameEventMeta): void { +export function setCounter(data: Event_SetCounter, meta: GameEventMeta): void { WebClient.instance.response.game.counterSet(meta.gameId, meta.playerId, data); } diff --git a/webclient/src/websocket/events/game/shuffle.ts b/webclient/src/websocket/events/game/shuffle.ts index b784bb634..9d1689472 100644 --- a/webclient/src/websocket/events/game/shuffle.ts +++ b/webclient/src/websocket/events/game/shuffle.ts @@ -1,6 +1,7 @@ -import type { Data, Enriched } from '@app/types'; +import type { Event_Shuffle } from '@app/generated'; +import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; import { WebClient } from '../../WebClient'; -export function shuffle(data: Data.Event_Shuffle, meta: Enriched.GameEventMeta): void { +export function shuffle(data: Event_Shuffle, meta: GameEventMeta): void { WebClient.instance.response.game.zoneShuffled(meta.gameId, meta.playerId, data); } diff --git a/webclient/src/websocket/events/room/index.ts b/webclient/src/websocket/events/room/index.ts index c02805659..2b27954d2 100644 --- a/webclient/src/websocket/events/room/index.ts +++ b/webclient/src/websocket/events/room/index.ts @@ -1,6 +1,15 @@ import type { GenExtension } from '@bufbuild/protobuf/codegenv2'; -import { Data } from '@app/types'; +import { + type RegistryEntry, + type RoomEvent, + makeEntry, + Event_JoinRoom_ext, + Event_LeaveRoom_ext, + Event_ListGames_ext, + Event_RemoveMessages_ext, + Event_RoomSay_ext, +} from '@app/generated'; import { joinRoom } from './joinRoom'; import { leaveRoom } from './leaveRoom'; @@ -8,20 +17,20 @@ import { listGames } from './listGames'; import { roomSay } from './roomSay'; import { removeMessages } from './removeMessages'; -type RoomRegistryEntry = Data.RegistryEntry; +type RoomRegistryEntry = RegistryEntry; export type RoomExtensionRegistry = RoomRegistryEntry[]; function makeRoomEntry( - ext: GenExtension, - handler: (value: V, roomEvent: Data.RoomEvent) => void, + ext: GenExtension, + handler: (value: V, roomEvent: RoomEvent) => void, ): RoomRegistryEntry { - return Data.makeEntry(ext, handler); + return makeEntry(ext, handler); } export const RoomEvents: RoomExtensionRegistry = [ - makeRoomEntry(Data.Event_JoinRoom_ext, joinRoom), - makeRoomEntry(Data.Event_LeaveRoom_ext, leaveRoom), - makeRoomEntry(Data.Event_ListGames_ext, listGames), - makeRoomEntry(Data.Event_RemoveMessages_ext, removeMessages), - makeRoomEntry(Data.Event_RoomSay_ext, roomSay), + makeRoomEntry(Event_JoinRoom_ext, joinRoom), + makeRoomEntry(Event_LeaveRoom_ext, leaveRoom), + makeRoomEntry(Event_ListGames_ext, listGames), + makeRoomEntry(Event_RemoveMessages_ext, removeMessages), + makeRoomEntry(Event_RoomSay_ext, roomSay), ]; diff --git a/webclient/src/websocket/events/room/joinRoom.ts b/webclient/src/websocket/events/room/joinRoom.ts index f4d31943c..203dcebe5 100644 --- a/webclient/src/websocket/events/room/joinRoom.ts +++ b/webclient/src/websocket/events/room/joinRoom.ts @@ -1,6 +1,6 @@ -import type { Data } from '@app/types'; +import type { Event_JoinRoom, RoomEvent } from '@app/generated'; import { WebClient } from '../../WebClient'; -export function joinRoom({ userInfo }: Data.Event_JoinRoom, { roomId }: Data.RoomEvent): void { +export function joinRoom({ userInfo }: Event_JoinRoom, { roomId }: RoomEvent): void { WebClient.instance.response.room.userJoined(roomId, userInfo); } diff --git a/webclient/src/websocket/events/room/leaveRoom.ts b/webclient/src/websocket/events/room/leaveRoom.ts index b03bda6e2..819e8f438 100644 --- a/webclient/src/websocket/events/room/leaveRoom.ts +++ b/webclient/src/websocket/events/room/leaveRoom.ts @@ -1,6 +1,6 @@ -import type { Data } from '@app/types'; +import type { Event_LeaveRoom, RoomEvent } from '@app/generated'; import { WebClient } from '../../WebClient'; -export function leaveRoom({ name }: Data.Event_LeaveRoom, { roomId }: Data.RoomEvent): void { +export function leaveRoom({ name }: Event_LeaveRoom, { roomId }: RoomEvent): void { WebClient.instance.response.room.userLeft(roomId, name); } diff --git a/webclient/src/websocket/events/room/listGames.ts b/webclient/src/websocket/events/room/listGames.ts index b96596223..a06d4742f 100644 --- a/webclient/src/websocket/events/room/listGames.ts +++ b/webclient/src/websocket/events/room/listGames.ts @@ -1,6 +1,6 @@ -import type { Data } from '@app/types'; +import type { Event_ListGames, RoomEvent } from '@app/generated'; import { WebClient } from '../../WebClient'; -export function listGames({ gameList }: Data.Event_ListGames, { roomId }: Data.RoomEvent): void { +export function listGames({ gameList }: Event_ListGames, { roomId }: RoomEvent): void { WebClient.instance.response.room.updateGames(roomId, gameList); } diff --git a/webclient/src/websocket/events/room/removeMessages.ts b/webclient/src/websocket/events/room/removeMessages.ts index 343635259..0c196438e 100644 --- a/webclient/src/websocket/events/room/removeMessages.ts +++ b/webclient/src/websocket/events/room/removeMessages.ts @@ -1,6 +1,6 @@ -import type { Data } from '@app/types'; +import type { Event_RemoveMessages, RoomEvent } from '@app/generated'; import { WebClient } from '../../WebClient'; -export function removeMessages({ name, amount }: Data.Event_RemoveMessages, { roomId }: Data.RoomEvent): void { +export function removeMessages({ name, amount }: Event_RemoveMessages, { roomId }: RoomEvent): void { WebClient.instance.response.room.removeMessages(roomId, name, amount); } diff --git a/webclient/src/websocket/events/room/roomEvents.spec.ts b/webclient/src/websocket/events/room/roomEvents.spec.ts index 142de91d1..3955b524d 100644 --- a/webclient/src/websocket/events/room/roomEvents.spec.ts +++ b/webclient/src/websocket/events/room/roomEvents.spec.ts @@ -1,21 +1,14 @@ -vi.mock('../../WebClient', () => ({ - WebClient: { - instance: { - response: { - room: { - userJoined: vi.fn(), - userLeft: vi.fn(), - updateGames: vi.fn(), - removeMessages: vi.fn(), - addMessage: vi.fn(), - }, - }, - }, - }, -})); +vi.mock('../../WebClient'); import { create } from '@bufbuild/protobuf'; -import { Data } from '@app/types'; +import { + Event_JoinRoomSchema, + Event_LeaveRoomSchema, + Event_ListGamesSchema, + Event_RemoveMessagesSchema, + Event_RoomSaySchema, + RoomEventSchema, +} from '@app/generated'; import { WebClient } from '../../WebClient'; import { joinRoom } from './joinRoom'; import { leaveRoom } from './leaveRoom'; @@ -23,12 +16,12 @@ import { listGames } from './listGames'; import { removeMessages } from './removeMessages'; import { roomSay } from './roomSay'; -const makeRoomEvent = (roomId: number) => create(Data.RoomEventSchema, { roomId }); +const makeRoomEvent = (roomId: number) => create(RoomEventSchema, { roomId }); describe('joinRoom room event', () => { it('calls response.room.userJoined with roomId and userInfo', () => { - const data = create(Data.Event_JoinRoomSchema, { userInfo: { name: 'alice' } }); + const data = create(Event_JoinRoomSchema, { userInfo: { name: 'alice' } }); joinRoom(data, makeRoomEvent(3)); expect(WebClient.instance.response.room.userJoined).toHaveBeenCalledWith(3, data.userInfo); }); @@ -37,7 +30,7 @@ describe('joinRoom room event', () => { describe('leaveRoom room event', () => { it('calls response.room.userLeft with roomId and name', () => { - leaveRoom(create(Data.Event_LeaveRoomSchema, { name: 'alice' }), makeRoomEvent(4)); + leaveRoom(create(Event_LeaveRoomSchema, { name: 'alice' }), makeRoomEvent(4)); expect(WebClient.instance.response.room.userLeft).toHaveBeenCalledWith(4, 'alice'); }); }); @@ -45,7 +38,7 @@ describe('leaveRoom room event', () => { describe('listGames room event', () => { it('calls response.room.updateGames with roomId and gameList', () => { - const data = create(Data.Event_ListGamesSchema, { gameList: [{ gameId: 1 }] }); + const data = create(Event_ListGamesSchema, { gameList: [{ gameId: 1 }] }); listGames(data, makeRoomEvent(5)); expect(WebClient.instance.response.room.updateGames).toHaveBeenCalledWith(5, data.gameList); }); @@ -54,7 +47,7 @@ describe('listGames room event', () => { describe('removeMessages room event', () => { it('calls response.room.removeMessages with roomId, name, amount', () => { - removeMessages(create(Data.Event_RemoveMessagesSchema, { name: 'bob', amount: 10 }), makeRoomEvent(6)); + removeMessages(create(Event_RemoveMessagesSchema, { name: 'bob', amount: 10 }), makeRoomEvent(6)); expect(WebClient.instance.response.room.removeMessages).toHaveBeenCalledWith(6, 'bob', 10); }); }); @@ -66,7 +59,7 @@ describe('roomSay room event', () => { afterEach(() => vi.useRealTimers()); it('calls response.room.addMessage with roomId and message', () => { - const data = create(Data.Event_RoomSaySchema, { message: 'hello' }); + const data = create(Event_RoomSaySchema, { message: 'hello' }); roomSay(data, makeRoomEvent(7)); expect(WebClient.instance.response.room.addMessage).toHaveBeenCalledWith(7, { ...data, timeReceived: 0 }); }); diff --git a/webclient/src/websocket/events/room/roomSay.ts b/webclient/src/websocket/events/room/roomSay.ts index c712bd89c..919189093 100644 --- a/webclient/src/websocket/events/room/roomSay.ts +++ b/webclient/src/websocket/events/room/roomSay.ts @@ -1,9 +1,7 @@ -import type { Data } from '@app/types'; -import { Enriched } from '@app/types'; +import type { Event_RoomSay, RoomEvent } from '@app/generated'; import { WebClient } from '../../WebClient'; - -export function roomSay(data: Data.Event_RoomSay, { roomId }: Data.RoomEvent): void { - const message: Enriched.Message = { ...data, timeReceived: Date.now() }; +export function roomSay(data: Event_RoomSay, { roomId }: RoomEvent): void { + const message = { ...data, timeReceived: Date.now() }; WebClient.instance.response.room.addMessage(roomId, message); } diff --git a/webclient/src/websocket/events/session/addToList.ts b/webclient/src/websocket/events/session/addToList.ts index 819234e94..a8e27a654 100644 --- a/webclient/src/websocket/events/session/addToList.ts +++ b/webclient/src/websocket/events/session/addToList.ts @@ -1,7 +1,7 @@ -import type { Data } from '@app/types'; +import type { Event_AddToList } from '@app/generated'; import { WebClient } from '../../WebClient'; -export function addToList({ listName, userInfo }: Data.Event_AddToList): void { +export function addToList({ listName, userInfo }: Event_AddToList): void { switch (listName) { case 'buddy': { WebClient.instance.response.session.addToBuddyList(userInfo); diff --git a/webclient/src/websocket/events/session/connectionClosed.ts b/webclient/src/websocket/events/session/connectionClosed.ts index fcde2fceb..b3422172f 100644 --- a/webclient/src/websocket/events/session/connectionClosed.ts +++ b/webclient/src/websocket/events/session/connectionClosed.ts @@ -1,7 +1,8 @@ -import { App, Data } from '@app/types'; +import { Event_ConnectionClosed_CloseReason, type Event_ConnectionClosed } from '@app/generated'; +import { StatusEnum } from '../../interfaces/StatusEnum'; import { updateStatus } from '../../commands/session'; -export function connectionClosed({ reason, reasonStr, endTime }: Data.Event_ConnectionClosed): void { +export function connectionClosed({ reason, reasonStr, endTime }: Event_ConnectionClosed): void { let message: string; // @TODO (5) @@ -9,35 +10,35 @@ export function connectionClosed({ reason, reasonStr, endTime }: Data.Event_Conn message = reasonStr; } else { switch (reason) { - case Data.Event_ConnectionClosed_CloseReason.USER_LIMIT_REACHED: + case Event_ConnectionClosed_CloseReason.USER_LIMIT_REACHED: message = 'The server has reached its maximum user capacity'; break; - case Data.Event_ConnectionClosed_CloseReason.TOO_MANY_CONNECTIONS: + case Event_ConnectionClosed_CloseReason.TOO_MANY_CONNECTIONS: message = 'There are too many concurrent connections from your address'; break; - case Data.Event_ConnectionClosed_CloseReason.BANNED: + case Event_ConnectionClosed_CloseReason.BANNED: message = typeof endTime === 'number' && endTime > 0 && Number.isFinite(endTime) ? `You are banned until ${new Date(endTime * 1000).toLocaleString()}` : 'You are banned'; break; - case Data.Event_ConnectionClosed_CloseReason.DEMOTED: + case Event_ConnectionClosed_CloseReason.DEMOTED: message = 'You were demoted'; break; - case Data.Event_ConnectionClosed_CloseReason.SERVER_SHUTDOWN: + case Event_ConnectionClosed_CloseReason.SERVER_SHUTDOWN: message = 'Scheduled server shutdown'; break; - case Data.Event_ConnectionClosed_CloseReason.USERNAMEINVALID: + case Event_ConnectionClosed_CloseReason.USERNAMEINVALID: message = 'Invalid username'; break; - case Data.Event_ConnectionClosed_CloseReason.LOGGEDINELSEWERE: + case Event_ConnectionClosed_CloseReason.LOGGEDINELSEWERE: message = 'You have been logged out due to logging in at another location'; break; - case Data.Event_ConnectionClosed_CloseReason.OTHER: + case Event_ConnectionClosed_CloseReason.OTHER: default: message = 'Unknown reason'; break; } } - updateStatus(App.StatusEnum.DISCONNECTED, message); + updateStatus(StatusEnum.DISCONNECTED, message); } diff --git a/webclient/src/websocket/events/session/gameJoined.ts b/webclient/src/websocket/events/session/gameJoined.ts index ed38a4ba1..83a555949 100644 --- a/webclient/src/websocket/events/session/gameJoined.ts +++ b/webclient/src/websocket/events/session/gameJoined.ts @@ -1,6 +1,6 @@ -import type { Data } from '@app/types'; +import type { Event_GameJoined } from '@app/generated'; import { WebClient } from '../../WebClient'; -export function gameJoined(gameJoined: Data.Event_GameJoined): void { +export function gameJoined(gameJoined: Event_GameJoined): void { WebClient.instance.response.session.gameJoined(gameJoined); } diff --git a/webclient/src/websocket/events/session/index.ts b/webclient/src/websocket/events/session/index.ts index 3d5935c9f..847ea3246 100644 --- a/webclient/src/websocket/events/session/index.ts +++ b/webclient/src/websocket/events/session/index.ts @@ -1,6 +1,24 @@ import type { GenExtension } from '@bufbuild/protobuf/codegenv2'; -import { Data } from '@app/types'; +import { + type RegistryEntry, + type SessionEvent, + makeEntry, + Event_AddToList_ext, + Event_ConnectionClosed_ext, + Event_GameJoined_ext, + Event_ListRooms_ext, + Event_NotifyUser_ext, + Event_RemoveFromList_ext, + Event_ReplayAdded_ext, + Event_ServerCompleteList_ext, + Event_ServerIdentification_ext, + Event_ServerMessage_ext, + Event_ServerShutdown_ext, + Event_UserJoined_ext, + Event_UserLeft_ext, + Event_UserMessage_ext, +} from '@app/generated'; import { addToList } from './addToList'; import { connectionClosed } from './connectionClosed'; @@ -17,29 +35,29 @@ import { userLeft } from './userLeft'; import { userMessage } from './userMessage'; import { gameJoined } from './gameJoined'; -type SessionRegistryEntry = Data.RegistryEntry; +type SessionRegistryEntry = RegistryEntry; export type SessionExtensionRegistry = SessionRegistryEntry[]; function makeSessionEntry( - ext: GenExtension, + ext: GenExtension, handler: (value: V) => void, ): SessionRegistryEntry { - return Data.makeEntry(ext, handler); + return makeEntry(ext, handler); } export const SessionEvents: SessionExtensionRegistry = [ - makeSessionEntry(Data.Event_AddToList_ext, addToList), - makeSessionEntry(Data.Event_ConnectionClosed_ext, connectionClosed), - makeSessionEntry(Data.Event_GameJoined_ext, gameJoined), - makeSessionEntry(Data.Event_ListRooms_ext, listRooms), - makeSessionEntry(Data.Event_NotifyUser_ext, notifyUser), - makeSessionEntry(Data.Event_RemoveFromList_ext, removeFromList), - makeSessionEntry(Data.Event_ReplayAdded_ext, replayAdded), - makeSessionEntry(Data.Event_ServerCompleteList_ext, serverCompleteList), - makeSessionEntry(Data.Event_ServerIdentification_ext, serverIdentification), - makeSessionEntry(Data.Event_ServerMessage_ext, serverMessage), - makeSessionEntry(Data.Event_ServerShutdown_ext, serverShutdown), - makeSessionEntry(Data.Event_UserJoined_ext, userJoined), - makeSessionEntry(Data.Event_UserLeft_ext, userLeft), - makeSessionEntry(Data.Event_UserMessage_ext, userMessage), + makeSessionEntry(Event_AddToList_ext, addToList), + makeSessionEntry(Event_ConnectionClosed_ext, connectionClosed), + makeSessionEntry(Event_GameJoined_ext, gameJoined), + makeSessionEntry(Event_ListRooms_ext, listRooms), + makeSessionEntry(Event_NotifyUser_ext, notifyUser), + makeSessionEntry(Event_RemoveFromList_ext, removeFromList), + makeSessionEntry(Event_ReplayAdded_ext, replayAdded), + makeSessionEntry(Event_ServerCompleteList_ext, serverCompleteList), + makeSessionEntry(Event_ServerIdentification_ext, serverIdentification), + makeSessionEntry(Event_ServerMessage_ext, serverMessage), + makeSessionEntry(Event_ServerShutdown_ext, serverShutdown), + makeSessionEntry(Event_UserJoined_ext, userJoined), + makeSessionEntry(Event_UserLeft_ext, userLeft), + makeSessionEntry(Event_UserMessage_ext, userMessage), ]; diff --git a/webclient/src/websocket/events/session/listRooms.ts b/webclient/src/websocket/events/session/listRooms.ts index 30f3285e6..8dac345a0 100644 --- a/webclient/src/websocket/events/session/listRooms.ts +++ b/webclient/src/websocket/events/session/listRooms.ts @@ -1,9 +1,9 @@ -import type { Data } from '@app/types'; +import type { Event_ListRooms } from '@app/generated'; import { CLIENT_OPTIONS } from '../../config'; import { joinRoom } from '../../commands/session'; import { WebClient } from '../../WebClient'; -export function listRooms({ roomList }: Data.Event_ListRooms): void { +export function listRooms({ roomList }: Event_ListRooms): void { WebClient.instance.response.room.updateRooms(roomList); if (CLIENT_OPTIONS.autojoinrooms) { diff --git a/webclient/src/websocket/events/session/notifyUser.ts b/webclient/src/websocket/events/session/notifyUser.ts index 8a6184edf..df8840781 100644 --- a/webclient/src/websocket/events/session/notifyUser.ts +++ b/webclient/src/websocket/events/session/notifyUser.ts @@ -1,6 +1,6 @@ -import type { Data } from '@app/types'; +import type { Event_NotifyUser } from '@app/generated'; import { WebClient } from '../../WebClient'; -export function notifyUser(payload: Data.Event_NotifyUser): void { +export function notifyUser(payload: Event_NotifyUser): void { WebClient.instance.response.session.notifyUser(payload); } diff --git a/webclient/src/websocket/events/session/removeFromList.ts b/webclient/src/websocket/events/session/removeFromList.ts index 3e5feae83..8b4db5b21 100644 --- a/webclient/src/websocket/events/session/removeFromList.ts +++ b/webclient/src/websocket/events/session/removeFromList.ts @@ -1,7 +1,7 @@ -import type { Data } from '@app/types'; +import type { Event_RemoveFromList } from '@app/generated'; import { WebClient } from '../../WebClient'; -export function removeFromList({ listName, userName }: Data.Event_RemoveFromList): void { +export function removeFromList({ listName, userName }: Event_RemoveFromList): void { switch (listName) { case 'buddy': { WebClient.instance.response.session.removeFromBuddyList(userName); diff --git a/webclient/src/websocket/events/session/replayAdded.ts b/webclient/src/websocket/events/session/replayAdded.ts index 9acc7135b..8fe1e2b89 100644 --- a/webclient/src/websocket/events/session/replayAdded.ts +++ b/webclient/src/websocket/events/session/replayAdded.ts @@ -1,6 +1,6 @@ -import type { Data } from '@app/types'; +import type { Event_ReplayAdded } from '@app/generated'; import { WebClient } from '../../WebClient'; -export function replayAdded({ matchInfo }: Data.Event_ReplayAdded): void { +export function replayAdded({ matchInfo }: Event_ReplayAdded): void { WebClient.instance.response.session.replayAdded(matchInfo); } diff --git a/webclient/src/websocket/events/session/serverCompleteList.ts b/webclient/src/websocket/events/session/serverCompleteList.ts index e8751bb32..df2b0c05a 100644 --- a/webclient/src/websocket/events/session/serverCompleteList.ts +++ b/webclient/src/websocket/events/session/serverCompleteList.ts @@ -1,7 +1,7 @@ -import type { Data } from '@app/types'; +import type { Event_ServerCompleteList } from '@app/generated'; import { WebClient } from '../../WebClient'; -export function serverCompleteList({ userList, roomList }: Data.Event_ServerCompleteList): void { +export function serverCompleteList({ userList, roomList }: Event_ServerCompleteList): void { WebClient.instance.response.session.updateUsers(userList); WebClient.instance.response.room.updateRooms(roomList); } diff --git a/webclient/src/websocket/events/session/serverIdentification.ts b/webclient/src/websocket/events/session/serverIdentification.ts index 79ac7a101..6c7956f3f 100644 --- a/webclient/src/websocket/events/session/serverIdentification.ts +++ b/webclient/src/websocket/events/session/serverIdentification.ts @@ -1,88 +1,93 @@ -import { App, Data, Enriched } from '@app/types'; - +import type { Event_ServerIdentification } from '@app/generated'; import { WebClient } from '../../WebClient'; +import { StatusEnum } from '../../interfaces/StatusEnum'; import { PROTOCOL_VERSION } from '../../config'; -import { - activate, - disconnect, - login, - register, - requestPasswordSalt, - forgotPasswordChallenge, - forgotPasswordRequest, - forgotPasswordReset, - updateStatus, -} from '../../commands/session'; +import { consumePendingOptions } from '../../utils/connectionState'; +import { WebSocketConnectReason } from '../../interfaces/ConnectOptions'; import { generateSalt, passwordSaltSupported } from '../../utils'; -export function serverIdentification(info: Data.Event_ServerIdentification): void { +import * as SessionCommands from '../../commands/session'; + +export function serverIdentification(info: Event_ServerIdentification): void { const { serverName, serverVersion, protocolVersion, serverOptions } = info; + const response = WebClient.instance.response; + if (protocolVersion !== PROTOCOL_VERSION) { - updateStatus(App.StatusEnum.DISCONNECTED, `Protocol version mismatch: ${protocolVersion}`); - disconnect(); + SessionCommands.updateStatus(StatusEnum.DISCONNECTED, `Protocol version mismatch: ${protocolVersion}`); + SessionCommands.disconnect(); return; } const getPasswordSalt = passwordSaltSupported(serverOptions); - const options = WebClient.instance.options; + const options = consumePendingOptions(); if (!options) { - updateStatus(App.StatusEnum.DISCONNECTED, 'Missing connection options'); - disconnect(); + SessionCommands.updateStatus(StatusEnum.DISCONNECTED, 'Missing connection options'); + SessionCommands.disconnect(); return; } - // Strip credentials before handing off to session commands — they travel as - // separate function args so they can't accidentally ride along in the - // typed options object that flows downstream. switch (options.reason) { - case App.WebSocketConnectReason.LOGIN: { + case WebSocketConnectReason.LOGIN: { const { password, ...rest } = options; - updateStatus(App.StatusEnum.LOGGING_IN, 'Logging In...'); + SessionCommands.updateStatus(StatusEnum.LOGGING_IN, 'Logging In...'); if (getPasswordSalt) { - requestPasswordSalt(rest, password); + SessionCommands.requestPasswordSalt(rest, + (salt) => SessionCommands.login(rest, password, salt), + () => { + response.session.loginFailed(); SessionCommands.disconnect(); + }, + ); } else { - login(rest, password); + SessionCommands.login(rest, password); } break; } - case App.WebSocketConnectReason.REGISTER: { + case WebSocketConnectReason.REGISTER: { const { password, ...rest } = options; const passwordSalt = getPasswordSalt ? generateSalt() : null; - register(rest, password, passwordSalt); + SessionCommands.register(rest, password, passwordSalt); break; } - case App.WebSocketConnectReason.ACTIVATE_ACCOUNT: { + case WebSocketConnectReason.ACTIVATE_ACCOUNT: { const { password, ...rest } = options; if (getPasswordSalt) { - requestPasswordSalt(rest, password); + SessionCommands.requestPasswordSalt(rest, + (salt) => SessionCommands.activate(rest, password, salt), + () => { + response.session.accountActivationFailed(); SessionCommands.disconnect(); + }, + ); } else { - activate(rest, password); + SessionCommands.activate(rest, password); } break; } - case App.WebSocketConnectReason.PASSWORD_RESET_REQUEST: - forgotPasswordRequest(options); + case WebSocketConnectReason.PASSWORD_RESET_REQUEST: + SessionCommands.forgotPasswordRequest(options); break; - case App.WebSocketConnectReason.PASSWORD_RESET_CHALLENGE: - forgotPasswordChallenge(options); + case WebSocketConnectReason.PASSWORD_RESET_CHALLENGE: + SessionCommands.forgotPasswordChallenge(options); break; - case App.WebSocketConnectReason.PASSWORD_RESET: { + case WebSocketConnectReason.PASSWORD_RESET: { const { newPassword, ...rest } = options; if (getPasswordSalt) { - requestPasswordSalt(rest, undefined, newPassword); + SessionCommands.requestPasswordSalt(rest, + (salt) => SessionCommands.forgotPasswordReset(rest, newPassword, salt), + () => { + response.session.resetPasswordFailed(); SessionCommands.disconnect(); + }, + ); } else { - forgotPasswordReset(rest, newPassword); + SessionCommands.forgotPasswordReset(rest, newPassword); } break; } default: { - const { reason } = options as Enriched.WebSocketConnectOptions; - updateStatus(App.StatusEnum.DISCONNECTED, `Unknown Connection Reason: ${reason}`); - disconnect(); + SessionCommands.updateStatus(StatusEnum.DISCONNECTED, `Unknown Connection Reason: ${(options as { reason: number }).reason}`); + SessionCommands.disconnect(); break; } } - WebClient.instance.options = null; - WebClient.instance.response.session.updateInfo(serverName, serverVersion); + response.session.updateInfo(serverName, serverVersion); } diff --git a/webclient/src/websocket/events/session/serverMessage.ts b/webclient/src/websocket/events/session/serverMessage.ts index 42bb14f82..c7f3b7b9d 100644 --- a/webclient/src/websocket/events/session/serverMessage.ts +++ b/webclient/src/websocket/events/session/serverMessage.ts @@ -1,7 +1,7 @@ -import type { Data } from '@app/types'; +import type { Event_ServerMessage } from '@app/generated'; import { WebClient } from '../../WebClient'; import { sanitizeHtml } from '../../utils'; -export function serverMessage({ message }: Data.Event_ServerMessage): void { +export function serverMessage({ message }: Event_ServerMessage): void { WebClient.instance.response.session.serverMessage(sanitizeHtml(message)); } diff --git a/webclient/src/websocket/events/session/serverShutdown.ts b/webclient/src/websocket/events/session/serverShutdown.ts index a0fce78c1..cdd404739 100644 --- a/webclient/src/websocket/events/session/serverShutdown.ts +++ b/webclient/src/websocket/events/session/serverShutdown.ts @@ -1,6 +1,6 @@ -import type { Data } from '@app/types'; +import type { Event_ServerShutdown } from '@app/generated'; import { WebClient } from '../../WebClient'; -export function serverShutdown(payload: Data.Event_ServerShutdown): void { +export function serverShutdown(payload: Event_ServerShutdown): void { WebClient.instance.response.session.serverShutdown(payload); } diff --git a/webclient/src/websocket/events/session/sessionEvents.spec.ts b/webclient/src/websocket/events/session/sessionEvents.spec.ts index 4ac961a5d..c627a6856 100644 --- a/webclient/src/websocket/events/session/sessionEvents.spec.ts +++ b/webclient/src/websocket/events/session/sessionEvents.spec.ts @@ -1,35 +1,7 @@ // Tests for simple session events that delegate 1:1 to SessionPersistence // or RoomPersistence with minimal logic. -vi.mock('../../WebClient', () => ({ - WebClient: { - instance: { - options: null, - response: { - session: { - gameJoined: vi.fn(), - notifyUser: vi.fn(), - replayAdded: vi.fn(), - serverMessage: vi.fn(), - serverShutdown: vi.fn(), - updateUsers: vi.fn(), - updateInfo: vi.fn(), - userJoined: vi.fn(), - userLeft: vi.fn(), - userMessage: vi.fn(), - addToBuddyList: vi.fn(), - addToIgnoreList: vi.fn(), - removeFromBuddyList: vi.fn(), - removeFromIgnoreList: vi.fn(), - playerPropertiesChanged: vi.fn(), - }, - room: { - updateRooms: vi.fn(), - }, - }, - }, - }, -})); +vi.mock('../../WebClient'); vi.mock('../../config', () => ({ CLIENT_OPTIONS: { autojoinrooms: false }, @@ -43,25 +15,52 @@ vi.mock('../../commands/session', () => ({ login: vi.fn(), register: vi.fn(), activate: vi.fn(), - requestPasswordSalt: vi.fn(), forgotPasswordRequest: vi.fn(), forgotPasswordChallenge: vi.fn(), forgotPasswordReset: vi.fn(), + requestPasswordSalt: vi.fn(), })); vi.mock('../../utils', () => ({ - generateSalt: vi.fn().mockReturnValue('newSalt'), - passwordSaltSupported: vi.fn().mockReturnValue(0), sanitizeHtml: vi.fn((msg: string) => msg), + generateSalt: vi.fn().mockReturnValue('randSalt'), + passwordSaltSupported: vi.fn().mockReturnValue(0), })); -import { App, Data, Enriched } from '@app/types'; +vi.mock('../../utils/connectionState', () => ({ + consumePendingOptions: vi.fn().mockReturnValue(null), +})); + +import { + Event_AddToListSchema, + Event_ConnectionClosedSchema, + Event_ConnectionClosed_CloseReason, + Event_GameJoinedSchema, + Event_ListRoomsSchema, + Event_NotifyUserSchema, + Event_RemoveFromListSchema, + Event_ReplayAddedSchema, + Event_ServerCompleteListSchema, + Event_ServerIdentificationSchema, + Event_ServerMessageSchema, + Event_ServerShutdownSchema, + Event_UserJoinedSchema, + Event_UserLeftSchema, + Event_UserMessageSchema, + ServerInfo_ReplayMatchSchema, + ServerInfo_RoomSchema, + ServerInfo_UserSchema, +} from '@app/generated'; import { create } from '@bufbuild/protobuf'; import { WebClient } from '../../WebClient'; import * as Config from '../../config'; import * as SessionCmds from '../../commands/session'; -import * as Utils from '../../utils'; +import { consumePendingOptions } from '../../utils/connectionState'; +import { passwordSaltSupported } from '../../utils'; +import { WebSocketConnectReason } from '../../interfaces/ConnectOptions'; +import { StatusEnum } from '../../interfaces/StatusEnum'; +import { Mock } from 'vitest'; import { gameJoined } from './gameJoined'; import { notifyUser } from './notifyUser'; import { replayAdded } from './replayAdded'; @@ -76,23 +75,16 @@ import { removeFromList } from './removeFromList'; import { listRooms } from './listRooms'; import { connectionClosed } from './connectionClosed'; import { serverIdentification } from './serverIdentification'; -import { Mock } from 'vitest'; - const ConfigMock = Config as { -readonly [K in keyof typeof Config]: (typeof Config)[K] }; -beforeEach(() => { - (Utils.generateSalt as Mock).mockReturnValue('newSalt'); - (Utils.passwordSaltSupported as Mock).mockReturnValue(0); -}); - // ---------------------------------------------------------------- // gameJoined // ---------------------------------------------------------------- describe('gameJoined', () => { it('calls WebClient.instance.response.session.gameJoined', () => { - const data = create(Data.Event_GameJoinedSchema, { playerId: 1 }); + const data = create(Event_GameJoinedSchema, { playerId: 1 }); gameJoined(data); expect(WebClient.instance.response.session.gameJoined).toHaveBeenCalledWith(data); }); @@ -104,7 +96,7 @@ describe('gameJoined', () => { describe('notifyUser', () => { it('calls WebClient.instance.response.session.notifyUser', () => { - const data = create(Data.Event_NotifyUserSchema, { warningReason: 'yo' }); + const data = create(Event_NotifyUserSchema, { warningReason: 'yo' }); notifyUser(data); expect(WebClient.instance.response.session.notifyUser).toHaveBeenCalledWith(data); }); @@ -116,8 +108,8 @@ describe('notifyUser', () => { describe('replayAdded', () => { it('calls WebClient.instance.response.session.replayAdded with matchInfo', () => { - const data = create(Data.Event_ReplayAddedSchema, { - matchInfo: create(Data.ServerInfo_ReplayMatchSchema, { gameId: 42 }), + const data = create(Event_ReplayAddedSchema, { + matchInfo: create(ServerInfo_ReplayMatchSchema, { gameId: 42 }), }); replayAdded(data); expect(WebClient.instance.response.session.replayAdded).toHaveBeenCalledWith(data.matchInfo); @@ -130,7 +122,7 @@ describe('replayAdded', () => { describe('serverCompleteList', () => { it('calls WebClient.instance.response.session.updateUsers and WebClient.instance.response.room.updateRooms', () => { - const data = create(Data.Event_ServerCompleteListSchema, { userList: [], roomList: [] }); + const data = create(Event_ServerCompleteListSchema, { userList: [], roomList: [] }); serverCompleteList(data); expect(WebClient.instance.response.session.updateUsers).toHaveBeenCalledWith(data.userList); expect(WebClient.instance.response.room.updateRooms).toHaveBeenCalledWith(data.roomList); @@ -143,7 +135,7 @@ describe('serverCompleteList', () => { describe('serverMessage', () => { it('calls WebClient.instance.response.session.serverMessage with message', () => { - serverMessage(create(Data.Event_ServerMessageSchema, { message: 'hello server' })); + serverMessage(create(Event_ServerMessageSchema, { message: 'hello server' })); expect(WebClient.instance.response.session.serverMessage).toHaveBeenCalledWith('hello server'); }); }); @@ -154,7 +146,7 @@ describe('serverMessage', () => { describe('serverShutdown', () => { it('calls WebClient.instance.response.session.serverShutdown', () => { - const payload = create(Data.Event_ServerShutdownSchema, { reason: 'maintenance' }); + const payload = create(Event_ServerShutdownSchema, { reason: 'maintenance' }); serverShutdown(payload); expect(WebClient.instance.response.session.serverShutdown).toHaveBeenCalledWith(payload); }); @@ -166,8 +158,8 @@ describe('serverShutdown', () => { describe('userJoined', () => { it('calls WebClient.instance.response.session.userJoined with userInfo', () => { - const data = create(Data.Event_UserJoinedSchema, { - userInfo: create(Data.ServerInfo_UserSchema, { name: 'alice' }), + const data = create(Event_UserJoinedSchema, { + userInfo: create(ServerInfo_UserSchema, { name: 'alice' }), }); userJoined(data); expect(WebClient.instance.response.session.userJoined).toHaveBeenCalledWith(data.userInfo); @@ -180,7 +172,7 @@ describe('userJoined', () => { describe('userLeft', () => { it('calls WebClient.instance.response.session.userLeft with name', () => { - userLeft(create(Data.Event_UserLeftSchema, { name: 'bob' })); + userLeft(create(Event_UserLeftSchema, { name: 'bob' })); expect(WebClient.instance.response.session.userLeft).toHaveBeenCalledWith('bob'); }); }); @@ -191,7 +183,7 @@ describe('userLeft', () => { describe('userMessage', () => { it('calls WebClient.instance.response.session.userMessage', () => { - const payload = create(Data.Event_UserMessageSchema, { senderName: 'alice', message: 'hi' }); + const payload = create(Event_UserMessageSchema, { senderName: 'alice', message: 'hi' }); userMessage(payload); expect(WebClient.instance.response.session.userMessage).toHaveBeenCalledWith(payload); }); @@ -210,25 +202,25 @@ describe('addToList', () => { }); it('buddy list → addToBuddyList', () => { - const data = create(Data.Event_AddToListSchema, { + const data = create(Event_AddToListSchema, { listName: 'buddy', - userInfo: create(Data.ServerInfo_UserSchema, { name: 'alice' }), + userInfo: create(ServerInfo_UserSchema, { name: 'alice' }), }); addToList(data); expect(WebClient.instance.response.session.addToBuddyList).toHaveBeenCalledWith(data.userInfo); }); it('ignore list → addToIgnoreList', () => { - const data = create(Data.Event_AddToListSchema, { + const data = create(Event_AddToListSchema, { listName: 'ignore', - userInfo: create(Data.ServerInfo_UserSchema, { name: 'bob' }), + userInfo: create(ServerInfo_UserSchema, { name: 'bob' }), }); addToList(data); expect(WebClient.instance.response.session.addToIgnoreList).toHaveBeenCalledWith(data.userInfo); }); it('unknown list → console.log', () => { - addToList(create(Data.Event_AddToListSchema, { listName: 'unknown' })); + addToList(create(Event_AddToListSchema, { listName: 'unknown' })); expect(logSpy).toHaveBeenCalled(); }); }); @@ -239,18 +231,18 @@ describe('addToList', () => { describe('removeFromList', () => { it('buddy list → removeFromBuddyList', () => { - removeFromList(create(Data.Event_RemoveFromListSchema, { listName: 'buddy', userName: 'alice' })); + removeFromList(create(Event_RemoveFromListSchema, { listName: 'buddy', userName: 'alice' })); expect(WebClient.instance.response.session.removeFromBuddyList).toHaveBeenCalledWith('alice'); }); it('ignore list → removeFromIgnoreList', () => { - removeFromList(create(Data.Event_RemoveFromListSchema, { listName: 'ignore', userName: 'bob' })); + removeFromList(create(Event_RemoveFromListSchema, { listName: 'ignore', userName: 'bob' })); expect(WebClient.instance.response.session.removeFromIgnoreList).toHaveBeenCalledWith('bob'); }); it('unknown list → console.log', () => { const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); - removeFromList(create(Data.Event_RemoveFromListSchema, { listName: 'other', userName: 'x' })); + removeFromList(create(Event_RemoveFromListSchema, { listName: 'other', userName: 'x' })); expect(logSpy).toHaveBeenCalled(); logSpy.mockRestore(); }); @@ -262,24 +254,24 @@ describe('removeFromList', () => { describe('listRooms', () => { it('calls WebClient.instance.response.room.updateRooms', () => { - listRooms(create(Data.Event_ListRoomsSchema, { roomList: [] })); + listRooms(create(Event_ListRoomsSchema, { roomList: [] })); expect(WebClient.instance.response.room.updateRooms).toHaveBeenCalledWith([]); }); it('does not call joinRoom when autojoinrooms is false', () => { ConfigMock.CLIENT_OPTIONS = { autojoinrooms: false }; - listRooms(create(Data.Event_ListRoomsSchema, { - roomList: [create(Data.ServerInfo_RoomSchema, { autoJoin: true, roomId: 1 })] + listRooms(create(Event_ListRoomsSchema, { + roomList: [create(ServerInfo_RoomSchema, { autoJoin: true, roomId: 1 })] })); expect(SessionCmds.joinRoom).not.toHaveBeenCalled(); }); it('calls joinRoom for autoJoin rooms when autojoinrooms is true', () => { ConfigMock.CLIENT_OPTIONS = { autojoinrooms: true }; - listRooms(create(Data.Event_ListRoomsSchema, { + listRooms(create(Event_ListRoomsSchema, { roomList: [ - create(Data.ServerInfo_RoomSchema, { autoJoin: true, roomId: 2 }), - create(Data.ServerInfo_RoomSchema, { autoJoin: false, roomId: 3 }) + create(ServerInfo_RoomSchema, { autoJoin: true, roomId: 2 }), + create(ServerInfo_RoomSchema, { autoJoin: false, roomId: 3 }) ] })); expect(SessionCmds.joinRoom).toHaveBeenCalledTimes(1); @@ -293,12 +285,12 @@ describe('listRooms', () => { describe('connectionClosed', () => { it('uses reasonStr when provided', () => { - connectionClosed(create(Data.Event_ConnectionClosedSchema, { reason: 0, reasonStr: 'custom' })); + connectionClosed(create(Event_ConnectionClosedSchema, { reason: 0, reasonStr: 'custom' })); expect(SessionCmds.updateStatus).toHaveBeenCalledWith(expect.anything(), 'custom'); }); it('USER_LIMIT_REACHED → specific message', () => { - connectionClosed(create(Data.Event_ConnectionClosedSchema, { reason: Data.Event_ConnectionClosed_CloseReason.USER_LIMIT_REACHED })); + connectionClosed(create(Event_ConnectionClosedSchema, { reason: Event_ConnectionClosed_CloseReason.USER_LIMIT_REACHED })); expect(SessionCmds.updateStatus).toHaveBeenCalledWith( expect.anything(), expect.stringContaining('maximum user capacity') @@ -306,43 +298,43 @@ describe('connectionClosed', () => { }); it('TOO_MANY_CONNECTIONS → specific message', () => { - connectionClosed(create(Data.Event_ConnectionClosedSchema, { reason: Data.Event_ConnectionClosed_CloseReason.TOO_MANY_CONNECTIONS })); + connectionClosed(create(Event_ConnectionClosedSchema, { reason: Event_ConnectionClosed_CloseReason.TOO_MANY_CONNECTIONS })); expect(SessionCmds.updateStatus).toHaveBeenCalledWith(expect.anything(), expect.stringContaining('too many concurrent')); }); it('BANNED → specific message', () => { - connectionClosed(create(Data.Event_ConnectionClosedSchema, { reason: Data.Event_ConnectionClosed_CloseReason.BANNED })); + connectionClosed(create(Event_ConnectionClosedSchema, { reason: Event_ConnectionClosed_CloseReason.BANNED })); expect(SessionCmds.updateStatus).toHaveBeenCalledWith(expect.anything(), 'You are banned'); }); it('DEMOTED → specific message', () => { - connectionClosed(create(Data.Event_ConnectionClosedSchema, { reason: Data.Event_ConnectionClosed_CloseReason.DEMOTED })); + connectionClosed(create(Event_ConnectionClosedSchema, { reason: Event_ConnectionClosed_CloseReason.DEMOTED })); expect(SessionCmds.updateStatus).toHaveBeenCalledWith(expect.anything(), expect.stringContaining('demoted')); }); it('SERVER_SHUTDOWN → specific message', () => { - connectionClosed(create(Data.Event_ConnectionClosedSchema, { reason: Data.Event_ConnectionClosed_CloseReason.SERVER_SHUTDOWN })); + connectionClosed(create(Event_ConnectionClosedSchema, { reason: Event_ConnectionClosed_CloseReason.SERVER_SHUTDOWN })); expect(SessionCmds.updateStatus).toHaveBeenCalledWith(expect.anything(), expect.stringContaining('shutdown')); }); it('USERNAMEINVALID → specific message', () => { - connectionClosed(create(Data.Event_ConnectionClosedSchema, { reason: Data.Event_ConnectionClosed_CloseReason.USERNAMEINVALID })); + connectionClosed(create(Event_ConnectionClosedSchema, { reason: Event_ConnectionClosed_CloseReason.USERNAMEINVALID })); expect(SessionCmds.updateStatus).toHaveBeenCalledWith(expect.anything(), expect.stringContaining('username')); }); it('LOGGEDINELSEWERE → specific message', () => { - connectionClosed(create(Data.Event_ConnectionClosedSchema, { reason: Data.Event_ConnectionClosed_CloseReason.LOGGEDINELSEWERE })); + connectionClosed(create(Event_ConnectionClosedSchema, { reason: Event_ConnectionClosed_CloseReason.LOGGEDINELSEWERE })); expect(SessionCmds.updateStatus).toHaveBeenCalledWith(expect.anything(), expect.stringContaining('logged out')); }); it('OTHER → "Unknown reason"', () => { - connectionClosed(create(Data.Event_ConnectionClosedSchema, { reason: Data.Event_ConnectionClosed_CloseReason.OTHER })); + connectionClosed(create(Event_ConnectionClosedSchema, { reason: Event_ConnectionClosed_CloseReason.OTHER })); expect(SessionCmds.updateStatus).toHaveBeenCalledWith(expect.anything(), 'Unknown reason'); }); it('BANNED with valid positive endTime → shows formatted date', () => { - connectionClosed(create(Data.Event_ConnectionClosedSchema, { - reason: Data.Event_ConnectionClosed_CloseReason.BANNED, endTime: 1700000000, + connectionClosed(create(Event_ConnectionClosedSchema, { + reason: Event_ConnectionClosed_CloseReason.BANNED, endTime: 1700000000, })); expect(SessionCmds.updateStatus).toHaveBeenCalledWith( expect.anything(), @@ -351,30 +343,30 @@ describe('connectionClosed', () => { }); it('BANNED with endTime = 0 → shows generic banned message', () => { - connectionClosed(create(Data.Event_ConnectionClosedSchema, { reason: Data.Event_ConnectionClosed_CloseReason.BANNED, endTime: 0 })); + connectionClosed(create(Event_ConnectionClosedSchema, { reason: Event_ConnectionClosed_CloseReason.BANNED, endTime: 0 })); expect(SessionCmds.updateStatus).toHaveBeenCalledWith(expect.anything(), 'You are banned'); }); it('BANNED with endTime = -1 → shows generic banned message', () => { - connectionClosed(create(Data.Event_ConnectionClosedSchema, { reason: Data.Event_ConnectionClosed_CloseReason.BANNED, endTime: -1 })); + connectionClosed(create(Event_ConnectionClosedSchema, { reason: Event_ConnectionClosed_CloseReason.BANNED, endTime: -1 })); expect(SessionCmds.updateStatus).toHaveBeenCalledWith(expect.anything(), 'You are banned'); }); it('BANNED with endTime = NaN → shows generic banned message', () => { - connectionClosed(create(Data.Event_ConnectionClosedSchema, { reason: Data.Event_ConnectionClosed_CloseReason.BANNED, endTime: NaN })); + connectionClosed(create(Event_ConnectionClosedSchema, { reason: Event_ConnectionClosed_CloseReason.BANNED, endTime: NaN })); expect(SessionCmds.updateStatus).toHaveBeenCalledWith(expect.anything(), 'You are banned'); }); it('BANNED with endTime = Infinity → shows generic banned message', () => { - connectionClosed(create(Data.Event_ConnectionClosedSchema, { - reason: Data.Event_ConnectionClosed_CloseReason.BANNED, endTime: Infinity, + connectionClosed(create(Event_ConnectionClosedSchema, { + reason: Event_ConnectionClosed_CloseReason.BANNED, endTime: Infinity, })); expect(SessionCmds.updateStatus).toHaveBeenCalledWith(expect.anything(), 'You are banned'); }); it('BANNED with reasonStr → uses reasonStr regardless of endTime', () => { - connectionClosed(create(Data.Event_ConnectionClosedSchema, - { reason: Data.Event_ConnectionClosed_CloseReason.BANNED, endTime: 0, reasonStr: 'custom ban reason' })); + connectionClosed(create(Event_ConnectionClosedSchema, + { reason: Event_ConnectionClosed_CloseReason.BANNED, endTime: 0, reasonStr: 'custom ban reason' })); expect(SessionCmds.updateStatus).toHaveBeenCalledWith(expect.anything(), 'custom ban reason'); }); }); @@ -383,157 +375,149 @@ describe('connectionClosed', () => { // serverIdentification // ---------------------------------------------------------------- describe('serverIdentification', () => { + const makeInfo = (overrides: Record = {}) => + create(Event_ServerIdentificationSchema, { + serverName: 'TestServer', + serverVersion: '1.0', + protocolVersion: 14, + serverOptions: 0, + ...overrides, + }); + + const makeLoginOptions = () => ({ + host: 'h', port: '1', userName: 'alice', password: 'pw', + reason: WebSocketConnectReason.LOGIN as const, + }); beforeEach(() => { - ConfigMock.PROTOCOL_VERSION = 14; - WebClient.instance.options = null; + (consumePendingOptions as Mock).mockReturnValue(null); + (passwordSaltSupported as Mock).mockReturnValue(0); }); - it('disconnects when protocolVersion mismatches', () => { - serverIdentification(create(Data.Event_ServerIdentificationSchema, - { serverName: 's', serverVersion: '1', protocolVersion: 99, serverOptions: 0 })); - expect(SessionCmds.updateStatus).toHaveBeenCalled(); + it('disconnects on protocol version mismatch', () => { + serverIdentification(makeInfo({ protocolVersion: 99 })); + expect(SessionCmds.updateStatus).toHaveBeenCalledWith(StatusEnum.DISCONNECTED, expect.stringContaining('Protocol version mismatch')); expect(SessionCmds.disconnect).toHaveBeenCalled(); + expect(WebClient.instance.response.session.updateInfo).not.toHaveBeenCalled(); }); - it('LOGIN reason without salt → calls login with password as separate param', () => { - WebClient.instance.options = { host: 'h', port: '1', userName: 'u', reason: App.WebSocketConnectReason.LOGIN, password: 'secret' }; - (Utils.passwordSaltSupported as Mock).mockReturnValue(0); - serverIdentification(create(Data.Event_ServerIdentificationSchema, - { serverName: 's', serverVersion: '1', protocolVersion: 14, serverOptions: 0 })); - expect(SessionCmds.login).toHaveBeenCalledWith( - expect.not.objectContaining({ password: expect.anything() }), - 'secret' - ); + it('disconnects when pending options are missing', () => { + serverIdentification(makeInfo()); + expect(SessionCmds.updateStatus).toHaveBeenCalledWith(StatusEnum.DISCONNECTED, 'Missing connection options'); + expect(SessionCmds.disconnect).toHaveBeenCalled(); + expect(WebClient.instance.response.session.updateInfo).not.toHaveBeenCalled(); }); - it('LOGIN reason with salt → calls requestPasswordSalt with password as separate param', () => { - WebClient.instance.options = { host: 'h', port: '1', userName: 'u', reason: App.WebSocketConnectReason.LOGIN, password: 'secret' }; - (Utils.passwordSaltSupported as Mock).mockReturnValue(1); - serverIdentification(create(Data.Event_ServerIdentificationSchema, - { serverName: 's', serverVersion: '1', protocolVersion: 14, serverOptions: 1 })); + it('LOGIN → calls login without salt when server does not support it', () => { + const opts = makeLoginOptions(); + (consumePendingOptions as Mock).mockReturnValue(opts); + serverIdentification(makeInfo()); + expect(SessionCmds.updateStatus).toHaveBeenCalledWith(StatusEnum.LOGGING_IN, 'Logging In...'); + expect(SessionCmds.login).toHaveBeenCalledWith(expect.objectContaining({ userName: 'alice' }), 'pw'); + expect(WebClient.instance.response.session.updateInfo).toHaveBeenCalledWith('TestServer', '1.0'); + }); + + it('LOGIN → calls requestPasswordSalt when server supports it', () => { + const opts = makeLoginOptions(); + (consumePendingOptions as Mock).mockReturnValue(opts); + (passwordSaltSupported as Mock).mockReturnValue(1); + serverIdentification(makeInfo({ serverOptions: 1 })); expect(SessionCmds.requestPasswordSalt).toHaveBeenCalledWith( - expect.not.objectContaining({ password: expect.anything() }), - 'secret' + expect.objectContaining({ userName: 'alice' }), + expect.any(Function), + expect.any(Function), ); }); - it('REGISTER reason without salt → calls register with password and null salt', () => { - WebClient.instance.options = { - host: 'h', port: '1', userName: 'u', email: 'e', country: 'US', realName: 'R', - reason: App.WebSocketConnectReason.REGISTER, password: 'secret', + it('REGISTER → calls register', () => { + const opts = { + host: 'h', port: '1', userName: 'alice', password: 'pw', + email: 'a@b.com', country: 'US', realName: 'Al', + reason: WebSocketConnectReason.REGISTER as const, }; - (Utils.passwordSaltSupported as Mock).mockReturnValue(0); - serverIdentification(create(Data.Event_ServerIdentificationSchema, - { serverName: 's', serverVersion: '1', protocolVersion: 14, serverOptions: 0 })); + (consumePendingOptions as Mock).mockReturnValue(opts); + serverIdentification(makeInfo()); expect(SessionCmds.register).toHaveBeenCalledWith( - expect.not.objectContaining({ password: expect.anything() }), - 'secret', - null + expect.objectContaining({ userName: 'alice' }), 'pw', null, ); }); - it('REGISTER reason with salt → calls register with password and generated salt', () => { - WebClient.instance.options = { - host: 'h', port: '1', userName: 'u', email: 'e', country: 'US', realName: 'R', - reason: App.WebSocketConnectReason.REGISTER, password: 'secret', + it('REGISTER with password salt → passes generated salt', () => { + const opts = { + host: 'h', port: '1', userName: 'alice', password: 'pw', + email: 'a@b.com', country: 'US', realName: 'Al', + reason: WebSocketConnectReason.REGISTER as const, }; - (Utils.passwordSaltSupported as Mock).mockReturnValue(1); - serverIdentification(create(Data.Event_ServerIdentificationSchema, - { serverName: 's', serverVersion: '1', protocolVersion: 14, serverOptions: 1 })); + (consumePendingOptions as Mock).mockReturnValue(opts); + (passwordSaltSupported as Mock).mockReturnValue(1); + serverIdentification(makeInfo({ serverOptions: 1 })); expect(SessionCmds.register).toHaveBeenCalledWith( - expect.not.objectContaining({ password: expect.anything() }), - 'secret', - 'newSalt' + expect.objectContaining({ userName: 'alice' }), 'pw', 'randSalt', ); }); - it('ACTIVATE_ACCOUNT reason without salt → calls activate with password as separate param', () => { - WebClient.instance.options = { - host: 'h', port: '1', userName: 'u', token: 'tok', - reason: App.WebSocketConnectReason.ACTIVATE_ACCOUNT, password: 'secret', + it('ACTIVATE_ACCOUNT → calls activate', () => { + const opts = { + host: 'h', port: '1', userName: 'alice', token: 'tok', password: 'pw', + reason: WebSocketConnectReason.ACTIVATE_ACCOUNT as const, }; - (Utils.passwordSaltSupported as Mock).mockReturnValue(0); - serverIdentification(create(Data.Event_ServerIdentificationSchema, - { serverName: 's', serverVersion: '1', protocolVersion: 14, serverOptions: 0 })); + (consumePendingOptions as Mock).mockReturnValue(opts); + serverIdentification(makeInfo()); expect(SessionCmds.activate).toHaveBeenCalledWith( - expect.not.objectContaining({ password: expect.anything() }), - 'secret' + expect.objectContaining({ userName: 'alice', token: 'tok' }), 'pw', ); }); - it('ACTIVATE_ACCOUNT reason with salt → calls requestPasswordSalt with password as separate param', () => { - WebClient.instance.options = { - host: 'h', port: '1', userName: 'u', token: 'tok', - reason: App.WebSocketConnectReason.ACTIVATE_ACCOUNT, password: 'secret', + it('PASSWORD_RESET_REQUEST → calls forgotPasswordRequest', () => { + const opts = { + host: 'h', port: '1', userName: 'alice', + reason: WebSocketConnectReason.PASSWORD_RESET_REQUEST as const, }; - (Utils.passwordSaltSupported as Mock).mockReturnValue(1); - serverIdentification(create(Data.Event_ServerIdentificationSchema, - { serverName: 's', serverVersion: '1', protocolVersion: 14, serverOptions: 1 })); - expect(SessionCmds.requestPasswordSalt).toHaveBeenCalledWith( - expect.not.objectContaining({ password: expect.anything() }), - 'secret' - ); + (consumePendingOptions as Mock).mockReturnValue(opts); + serverIdentification(makeInfo()); + expect(SessionCmds.forgotPasswordRequest).toHaveBeenCalledWith(opts); }); - it('PASSWORD_RESET_REQUEST reason → calls forgotPasswordRequest', () => { - WebClient.instance.options = { host: 'h', port: '1', userName: 'u', reason: App.WebSocketConnectReason.PASSWORD_RESET_REQUEST }; - serverIdentification(create(Data.Event_ServerIdentificationSchema, - { serverName: 's', serverVersion: '1', protocolVersion: 14, serverOptions: 0 })); - expect(SessionCmds.forgotPasswordRequest).toHaveBeenCalled(); + it('PASSWORD_RESET_CHALLENGE → calls forgotPasswordChallenge', () => { + const opts = { + host: 'h', port: '1', userName: 'alice', email: 'a@b.com', + reason: WebSocketConnectReason.PASSWORD_RESET_CHALLENGE as const, + }; + (consumePendingOptions as Mock).mockReturnValue(opts); + serverIdentification(makeInfo()); + expect(SessionCmds.forgotPasswordChallenge).toHaveBeenCalledWith(opts); }); - it('PASSWORD_RESET_CHALLENGE reason → calls forgotPasswordChallenge', () => { - WebClient.instance.options = { - host: 'h', port: '1', userName: 'u', email: 'e', reason: App.WebSocketConnectReason.PASSWORD_RESET_CHALLENGE, + it('PASSWORD_RESET → calls forgotPasswordReset without salt', () => { + const opts = { + host: 'h', port: '1', userName: 'alice', token: 'tok', newPassword: 'newpw', + reason: WebSocketConnectReason.PASSWORD_RESET as const, }; - serverIdentification(create(Data.Event_ServerIdentificationSchema, - { serverName: 's', serverVersion: '1', protocolVersion: 14, serverOptions: 0 })); - expect(SessionCmds.forgotPasswordChallenge).toHaveBeenCalled(); - }); - - it('PASSWORD_RESET reason without salt → calls forgotPasswordReset with newPassword as separate param', () => { - WebClient.instance.options = { - host: 'h', port: '1', userName: 'u', token: 'tok', - reason: App.WebSocketConnectReason.PASSWORD_RESET, newPassword: 'newpw', - }; - (Utils.passwordSaltSupported as Mock).mockReturnValue(0); - serverIdentification(create(Data.Event_ServerIdentificationSchema, - { serverName: 's', serverVersion: '1', protocolVersion: 14, serverOptions: 0 })); + (consumePendingOptions as Mock).mockReturnValue(opts); + serverIdentification(makeInfo()); expect(SessionCmds.forgotPasswordReset).toHaveBeenCalledWith( - expect.not.objectContaining({ newPassword: expect.anything() }), - 'newpw' + expect.objectContaining({ userName: 'alice', token: 'tok' }), 'newpw', ); }); - it('PASSWORD_RESET reason with salt → calls requestPasswordSalt with newPassword as separate param', () => { - WebClient.instance.options = { - host: 'h', port: '1', userName: 'u', token: 'tok', - reason: App.WebSocketConnectReason.PASSWORD_RESET, newPassword: 'newpw', + it('PASSWORD_RESET with salt → calls requestPasswordSalt', () => { + const opts = { + host: 'h', port: '1', userName: 'alice', token: 'tok', newPassword: 'newpw', + reason: WebSocketConnectReason.PASSWORD_RESET as const, }; - (Utils.passwordSaltSupported as Mock).mockReturnValue(1); - serverIdentification(create(Data.Event_ServerIdentificationSchema, - { serverName: 's', serverVersion: '1', protocolVersion: 14, serverOptions: 1 })); + (consumePendingOptions as Mock).mockReturnValue(opts); + (passwordSaltSupported as Mock).mockReturnValue(1); + serverIdentification(makeInfo({ serverOptions: 1 })); expect(SessionCmds.requestPasswordSalt).toHaveBeenCalledWith( - expect.not.objectContaining({ newPassword: expect.anything() }), - undefined, - 'newpw' + expect.objectContaining({ userName: 'alice' }), + expect.any(Function), + expect.any(Function), ); }); - it('unknown reason → updateStatus DISCONNECTED and disconnect', () => { - WebClient.instance.options = { host: 'h', port: '1', reason: 999 as App.WebSocketConnectReason } as Enriched.WebSocketConnectOptions; - serverIdentification(create(Data.Event_ServerIdentificationSchema, - { serverName: 's', serverVersion: '1', protocolVersion: 14, serverOptions: 0 })); - expect(SessionCmds.updateStatus).toHaveBeenCalled(); - expect(SessionCmds.disconnect).toHaveBeenCalled(); - }); - - it('resets WebClient.instance.options and calls WebClient.instance.response.session.updateInfo', () => { - WebClient.instance.options = { host: 'h', port: '1', userName: 'u', reason: App.WebSocketConnectReason.LOGIN }; - serverIdentification(create(Data.Event_ServerIdentificationSchema, - { serverName: 'myServer', serverVersion: '2.0', protocolVersion: 14, serverOptions: 0 })); - expect(WebClient.instance.response.session.updateInfo).toHaveBeenCalledWith('myServer', '2.0'); - expect(WebClient.instance.options).toBeNull(); + it('always calls updateInfo after successful routing', () => { + (consumePendingOptions as Mock).mockReturnValue(makeLoginOptions()); + serverIdentification(makeInfo()); + expect(WebClient.instance.response.session.updateInfo).toHaveBeenCalledWith('TestServer', '1.0'); }); }); diff --git a/webclient/src/websocket/events/session/userJoined.ts b/webclient/src/websocket/events/session/userJoined.ts index e474bf6e0..5f0e6014d 100644 --- a/webclient/src/websocket/events/session/userJoined.ts +++ b/webclient/src/websocket/events/session/userJoined.ts @@ -1,6 +1,6 @@ -import type { Data } from '@app/types'; +import type { Event_UserJoined } from '@app/generated'; import { WebClient } from '../../WebClient'; -export function userJoined({ userInfo }: Data.Event_UserJoined): void { +export function userJoined({ userInfo }: Event_UserJoined): void { WebClient.instance.response.session.userJoined(userInfo); } diff --git a/webclient/src/websocket/events/session/userLeft.ts b/webclient/src/websocket/events/session/userLeft.ts index fe20593da..f53bff2d4 100644 --- a/webclient/src/websocket/events/session/userLeft.ts +++ b/webclient/src/websocket/events/session/userLeft.ts @@ -1,6 +1,6 @@ -import type { Data } from '@app/types'; +import type { Event_UserLeft } from '@app/generated'; import { WebClient } from '../../WebClient'; -export function userLeft({ name }: Data.Event_UserLeft): void { +export function userLeft({ name }: Event_UserLeft): void { WebClient.instance.response.session.userLeft(name); } diff --git a/webclient/src/websocket/events/session/userMessage.ts b/webclient/src/websocket/events/session/userMessage.ts index aecbd01b0..5b3ca5905 100644 --- a/webclient/src/websocket/events/session/userMessage.ts +++ b/webclient/src/websocket/events/session/userMessage.ts @@ -1,6 +1,6 @@ -import type { Data } from '@app/types'; +import type { Event_UserMessage } from '@app/generated'; import { WebClient } from '../../WebClient'; -export function userMessage(payload: Data.Event_UserMessage): void { +export function userMessage(payload: Event_UserMessage): void { WebClient.instance.response.session.userMessage(payload); } diff --git a/webclient/src/websocket/index.ts b/webclient/src/websocket/index.ts index 61a67c4db..9d0cd9481 100644 --- a/webclient/src/websocket/index.ts +++ b/webclient/src/websocket/index.ts @@ -2,3 +2,31 @@ export * from './commands'; export * from './interfaces'; export { WebClient } from './WebClient'; +export { StatusEnum } from './interfaces/StatusEnum'; +export type { WebClientConfig, ConnectTarget } from './interfaces/WebClientConfig'; +export type { + KeyOf, + GameEventMeta, + WebSocketSessionResponseOverrides, + WebSocketRoomResponseOverrides, +} from './interfaces/WebSocketConfig'; + +export { SessionEvents } from './events/session'; +export { RoomEvents } from './events/room'; +export { GameEvents } from './events/game'; + +export { generateSalt, passwordSaltSupported, hashPassword } from './utils'; + +export { WebSocketConnectReason } from './interfaces/ConnectOptions'; +export type { + LoginConnectOptions, + RegisterConnectOptions, + ActivateConnectOptions, + PasswordResetRequestConnectOptions, + PasswordResetChallengeConnectOptions, + PasswordResetConnectOptions, + TestConnectionOptions, + WebSocketConnectOptions, +} from './interfaces/ConnectOptions'; + +export { setPendingOptions, consumePendingOptions } from './utils/connectionState'; diff --git a/webclient/src/websocket/interfaces/ConnectOptions.ts b/webclient/src/websocket/interfaces/ConnectOptions.ts new file mode 100644 index 000000000..a74402fb4 --- /dev/null +++ b/webclient/src/websocket/interfaces/ConnectOptions.ts @@ -0,0 +1,82 @@ +import type { ConnectTarget } from './WebClientConfig'; + +export enum WebSocketConnectReason { + LOGIN, + REGISTER, + ACTIVATE_ACCOUNT, + PASSWORD_RESET_REQUEST, + PASSWORD_RESET_CHALLENGE, + PASSWORD_RESET, + TEST_CONNECTION, +} + +// ── Connect options ─────────────────────────────────────────────────────────── +// Each variant is the enriched input for one session flow: the network +// transport fields (host/port) + the subset of proto Command_* fields the UI +// actually produces (user-entered credentials, tokens, email, etc.) + a +// `reason` discriminator so the websocket layer can route. + +interface ConnectTransport extends ConnectTarget { + keepalive?: number; + autojoinrooms?: boolean; + clientid?: string; +} + +export interface LoginConnectOptions extends ConnectTransport { + reason: WebSocketConnectReason.LOGIN; + userName: string; + password?: string; + hashedPassword?: string; +} + +export interface RegisterConnectOptions extends ConnectTransport { + reason: WebSocketConnectReason.REGISTER; + userName: string; + password: string; + email: string; + country: string; + realName: string; +} + +export interface ActivateConnectOptions extends ConnectTransport { + reason: WebSocketConnectReason.ACTIVATE_ACCOUNT; + userName: string; + token: string; + /** Plaintext password carried through so post-activation auto-login can hash it. */ + password?: string; +} + +export interface PasswordResetRequestConnectOptions extends ConnectTransport { + reason: WebSocketConnectReason.PASSWORD_RESET_REQUEST; + userName: string; +} + +export interface PasswordResetChallengeConnectOptions extends ConnectTransport { + reason: WebSocketConnectReason.PASSWORD_RESET_CHALLENGE; + userName: string; + email: string; +} + +export interface PasswordResetConnectOptions extends ConnectTransport { + reason: WebSocketConnectReason.PASSWORD_RESET; + userName: string; + token: string; + newPassword: string; +} + +/** + * Test connection has no proto command -- it just opens and closes a socket to + * verify reachability. + */ +export interface TestConnectionOptions extends ConnectTransport { + reason: WebSocketConnectReason.TEST_CONNECTION; +} + +export type WebSocketConnectOptions = + | LoginConnectOptions + | RegisterConnectOptions + | ActivateConnectOptions + | PasswordResetRequestConnectOptions + | PasswordResetChallengeConnectOptions + | PasswordResetConnectOptions + | TestConnectionOptions; diff --git a/webclient/src/websocket/interfaces/StatusEnum.ts b/webclient/src/websocket/interfaces/StatusEnum.ts new file mode 100644 index 000000000..179bdf675 --- /dev/null +++ b/webclient/src/websocket/interfaces/StatusEnum.ts @@ -0,0 +1,8 @@ +export enum StatusEnum { + DISCONNECTED, + CONNECTING, + CONNECTED, + LOGGING_IN, + LOGGED_IN, + DISCONNECTING = 99 +} diff --git a/webclient/src/websocket/interfaces/WebClientConfig.ts b/webclient/src/websocket/interfaces/WebClientConfig.ts new file mode 100644 index 000000000..6ff860009 --- /dev/null +++ b/webclient/src/websocket/interfaces/WebClientConfig.ts @@ -0,0 +1,24 @@ +import type { + RegistryEntry, + SessionEvent, + RoomEvent, + GameEvent, +} from '@app/generated'; + +import type { GameEventMeta } from './WebSocketConfig'; +import type { IWebClientResponse } from '.'; + +export interface ConnectTarget { + host: string; + port: string; +} + +export interface WebClientConfig { + response: IWebClientResponse; + + sessionEvents: RegistryEntry[]; + roomEvents: RegistryEntry[]; + gameEvents: RegistryEntry[]; + + keepAliveFn(pingReceived: () => void): void; +} diff --git a/webclient/src/websocket/interfaces/WebClientRequest.ts b/webclient/src/websocket/interfaces/WebClientRequest.ts index ba44e9841..16db5fd08 100644 --- a/webclient/src/websocket/interfaces/WebClientRequest.ts +++ b/webclient/src/websocket/interfaces/WebClientRequest.ts @@ -1,13 +1,68 @@ -import { Data, Enriched } from '@app/types'; +import type { + LoginParams, + RegisterParams, + ActivateParams, + ForgotPasswordRequestParams, + ForgotPasswordChallengeParams, + ForgotPasswordResetParams, + ViewLogHistoryParams, + KickFromGameParams, + GameSayParams, + ReadyStartParams, + SetActivePhaseParams, + MoveCardParams, + FlipCardParams, + AttachCardParams, + CreateTokenParams, + SetCardAttrParams, + SetCardCounterParams, + IncCardCounterParams, + DrawCardsParams, + CreateArrowParams, + DeleteArrowParams, + CreateCounterParams, + SetCounterParams, + IncCounterParams, + DelCounterParams, + ShuffleParams, + DumpZoneParams, + RevealCardsParams, + ChangeZonePropertiesParams, + DeckSelectParams, + SetSideboardPlanParams, + SetSideboardLockParams, + MulliganParams, + RollDieParams, + GameCommand, +} from '@app/generated'; -export interface IAuthenticationRequest { - login(options: Omit): void; - testConnection(options: Omit): void; - register(options: Omit): void; - activateAccount(options: Omit): void; - resetPasswordRequest(options: Omit): void; - resetPasswordChallenge(options: Omit): void; - resetPassword(options: Omit): void; +import type { ConnectTarget } from './WebClientConfig'; +import type { KeyOf } from './WebSocketConfig'; + +// ── Auth request type map ──────────────────────────────────────────────────── +// Keys = generated *Params type names composed with ConnectTarget. +// @app/api overrides these with Enriched connect option types. + +export interface AuthRequestMap { + LoginParams: ConnectTarget & LoginParams; + ConnectTarget: ConnectTarget; + RegisterParams: ConnectTarget & RegisterParams; + ActivateParams: ConnectTarget & ActivateParams; + ForgotPasswordRequestParams: ConnectTarget & ForgotPasswordRequestParams; + ForgotPasswordChallengeParams: ConnectTarget & ForgotPasswordChallengeParams; + ForgotPasswordResetParams: ConnectTarget & ForgotPasswordResetParams; +} + +type AK = KeyOf; + +export interface IAuthenticationRequest { + login(options: T[AK]): void; + testConnection(options: T[AK]): void; + register(options: T[AK]): void; + activateAccount(options: T[AK]): void; + resetPasswordRequest(options: T[AK]): void; + resetPasswordChallenge(options: T[AK]): void; + resetPassword(options: T[AK]): void; disconnect(): void; } @@ -52,49 +107,51 @@ export interface IModeratorRequest { getBanHistory(userName: string): void; getWarnHistory(userName: string): void; getWarnList(modName: string, userName: string, userClientid: string): void; - viewLogHistory(filters: Data.ViewLogHistoryParams): void; + viewLogHistory(filters: ViewLogHistoryParams): void; warnUser(userName: string, reason: string, clientid?: string, removeMessages?: number): void; } export interface IGameRequest { leaveGame(gameId: number): void; - kickFromGame(gameId: number, params: Data.KickFromGameParams): void; - gameSay(gameId: number, params: Data.GameSayParams): void; - readyStart(gameId: number, params: Data.ReadyStartParams): void; + kickFromGame(gameId: number, params: KickFromGameParams): void; + gameSay(gameId: number, params: GameSayParams): void; + readyStart(gameId: number, params: ReadyStartParams): void; concede(gameId: number): void; unconcede(gameId: number): void; - judge(gameId: number, targetId: number, innerGameCommand: Data.GameCommand): void; + judge(gameId: number, targetId: number, innerGameCommand: GameCommand): void; nextTurn(gameId: number): void; - setActivePhase(gameId: number, params: Data.SetActivePhaseParams): void; + setActivePhase(gameId: number, params: SetActivePhaseParams): void; reverseTurn(gameId: number): void; - moveCard(gameId: number, params: Data.MoveCardParams): void; - flipCard(gameId: number, params: Data.FlipCardParams): void; - attachCard(gameId: number, params: Data.AttachCardParams): void; - createToken(gameId: number, params: Data.CreateTokenParams): void; - setCardAttr(gameId: number, params: Data.SetCardAttrParams): void; - setCardCounter(gameId: number, params: Data.SetCardCounterParams): void; - incCardCounter(gameId: number, params: Data.IncCardCounterParams): void; - drawCards(gameId: number, params: Data.DrawCardsParams): void; + moveCard(gameId: number, params: MoveCardParams): void; + flipCard(gameId: number, params: FlipCardParams): void; + attachCard(gameId: number, params: AttachCardParams): void; + createToken(gameId: number, params: CreateTokenParams): void; + setCardAttr(gameId: number, params: SetCardAttrParams): void; + setCardCounter(gameId: number, params: SetCardCounterParams): void; + incCardCounter(gameId: number, params: IncCardCounterParams): void; + drawCards(gameId: number, params: DrawCardsParams): void; undoDraw(gameId: number): void; - createArrow(gameId: number, params: Data.CreateArrowParams): void; - deleteArrow(gameId: number, params: Data.DeleteArrowParams): void; - createCounter(gameId: number, params: Data.CreateCounterParams): void; - setCounter(gameId: number, params: Data.SetCounterParams): void; - incCounter(gameId: number, params: Data.IncCounterParams): void; - delCounter(gameId: number, params: Data.DelCounterParams): void; - shuffle(gameId: number, params: Data.ShuffleParams): void; - dumpZone(gameId: number, params: Data.DumpZoneParams): void; - revealCards(gameId: number, params: Data.RevealCardsParams): void; - changeZoneProperties(gameId: number, params: Data.ChangeZonePropertiesParams): void; - deckSelect(gameId: number, params: Data.DeckSelectParams): void; - setSideboardPlan(gameId: number, params: Data.SetSideboardPlanParams): void; - setSideboardLock(gameId: number, params: Data.SetSideboardLockParams): void; - mulligan(gameId: number, params: Data.MulliganParams): void; - rollDie(gameId: number, params: Data.RollDieParams): void; + createArrow(gameId: number, params: CreateArrowParams): void; + deleteArrow(gameId: number, params: DeleteArrowParams): void; + createCounter(gameId: number, params: CreateCounterParams): void; + setCounter(gameId: number, params: SetCounterParams): void; + incCounter(gameId: number, params: IncCounterParams): void; + delCounter(gameId: number, params: DelCounterParams): void; + shuffle(gameId: number, params: ShuffleParams): void; + dumpZone(gameId: number, params: DumpZoneParams): void; + revealCards(gameId: number, params: RevealCardsParams): void; + changeZoneProperties(gameId: number, params: ChangeZonePropertiesParams): void; + deckSelect(gameId: number, params: DeckSelectParams): void; + setSideboardPlan(gameId: number, params: SetSideboardPlanParams): void; + setSideboardLock(gameId: number, params: SetSideboardLockParams): void; + mulligan(gameId: number, params: MulliganParams): void; + rollDie(gameId: number, params: RollDieParams): void; } -export interface IWebClientRequest { - authentication: IAuthenticationRequest; +export interface IWebClientRequest< + A extends AuthRequestMap = AuthRequestMap, +> { + authentication: IAuthenticationRequest; session: ISessionRequest; rooms: IRoomsRequest; game: IGameRequest; diff --git a/webclient/src/websocket/interfaces/WebClientResponse.ts b/webclient/src/websocket/interfaces/WebClientResponse.ts index 8a60fc4e9..cf3bd40d5 100644 --- a/webclient/src/websocket/interfaces/WebClientResponse.ts +++ b/webclient/src/websocket/interfaces/WebClientResponse.ts @@ -1,28 +1,79 @@ -import { App, Data, Enriched } from '@app/types'; +import type { + Response_Login, + Response, + Response_GetGamesOfUser, + Response_DeckList, + Response_DeckDownload, + Response_ReplayDownload, + Response_WarnList, + ResponseMap, + Event_RoomSay, + Event_GameJoined, + Event_GameStateChanged, + Event_MoveCard, + Event_FlipCard, + Event_DestroyCard, + Event_AttachCard, + Event_CreateToken, + Event_SetCardAttr, + Event_SetCardCounter, + Event_CreateArrow, + Event_DeleteArrow, + Event_CreateCounter, + Event_SetCounter, + Event_DelCounter, + Event_DrawCards, + Event_RevealCards, + Event_Shuffle, + Event_RollDie, + Event_DumpZone, + Event_ChangeZoneProperties, + Event_NotifyUser, + Event_PlayerPropertiesChanged, + Event_ServerShutdown, + Event_UserMessage, + RoomEventMap, + ServerInfo_User, + ServerInfo_Room, + ServerInfo_Game, + ServerInfo_PlayerProperties, + ServerInfo_Ban, + ServerInfo_ChatMessage, + ServerInfo_Warning, + ServerInfo_DeckStorage_TreeItem, + ServerInfo_ReplayMatch, +} from '@app/generated'; -export interface ISessionResponse { +import type { StatusEnum } from './StatusEnum'; +import type { + KeyOf, + WebSocketSessionResponseOverrides, + WebSocketRoomResponseOverrides, +} from './WebSocketConfig'; + +export interface ISessionResponse { initialized(): void; connectionAttempted(): void; clearStore(): void; - loginSuccessful(options: Enriched.LoginSuccessContext): void; + loginSuccessful(result: T[KeyOf]): void; loginFailed(): void; connectionFailed(): void; testConnectionSuccessful(): void; testConnectionFailed(): void; - updateBuddyList(buddyList: Data.ServerInfo_User[]): void; - addToBuddyList(user: Data.ServerInfo_User): void; + updateBuddyList(buddyList: ServerInfo_User[]): void; + addToBuddyList(user: ServerInfo_User): void; removeFromBuddyList(userName: string): void; - updateIgnoreList(ignoreList: Data.ServerInfo_User[]): void; - addToIgnoreList(user: Data.ServerInfo_User): void; + updateIgnoreList(ignoreList: ServerInfo_User[]): void; + addToIgnoreList(user: ServerInfo_User): void; removeFromIgnoreList(userName: string): void; updateInfo(name: string, version: string): void; - updateStatus(state: App.StatusEnum, description: string): void; - updateUser(user: Data.ServerInfo_User): void; - updateUsers(users: Data.ServerInfo_User[]): void; - userJoined(user: Data.ServerInfo_User): void; + updateStatus(state: StatusEnum, description: string): void; + updateUser(user: ServerInfo_User): void; + updateUsers(users: ServerInfo_User[]): void; + userJoined(user: ServerInfo_User): void; userLeft(userName: string): void; serverMessage(message: string): void; - accountAwaitingActivation(options: Enriched.PendingActivationContext): void; + accountAwaitingActivation(result: T[KeyOf]): void; accountActivationSuccess(): void; accountActivationFailed(): void; registrationRequiresEmail(): void; @@ -38,36 +89,36 @@ export interface ISessionResponse { accountPasswordChange(): void; accountEditChanged(realName?: string, email?: string, country?: string): void; accountImageChanged(avatarBmp: Uint8Array): void; - getUserInfo(userInfo: Data.ServerInfo_User): void; - getGamesOfUser(userName: string, response: Data.Response_GetGamesOfUser): void; - gameJoined(gameJoinedData: Data.Event_GameJoined): void; - notifyUser(notification: Data.Event_NotifyUser): void; - playerPropertiesChanged(gameId: number, playerId: number, payload: Data.Event_PlayerPropertiesChanged): void; - serverShutdown(data: Data.Event_ServerShutdown): void; - userMessage(messageData: Data.Event_UserMessage): void; + getUserInfo(userInfo: ServerInfo_User): void; + getGamesOfUser(userName: string, response: Response_GetGamesOfUser): void; + gameJoined(gameJoinedData: Event_GameJoined): void; + notifyUser(notification: Event_NotifyUser): void; + playerPropertiesChanged(gameId: number, playerId: number, payload: Event_PlayerPropertiesChanged): void; + serverShutdown(data: Event_ServerShutdown): void; + userMessage(messageData: Event_UserMessage): void; addToList(list: string, userName: string): void; removeFromList(list: string, userName: string): void; deleteServerDeck(deckId: number): void; - updateServerDecks(deckList: Data.Response_DeckList): void; - uploadServerDeck(path: string, treeItem: Data.ServerInfo_DeckStorage_TreeItem): void; - downloadServerDeck(deckId: number, response: Data.Response_DeckDownload): void; + updateServerDecks(deckList: Response_DeckList): void; + uploadServerDeck(path: string, treeItem: ServerInfo_DeckStorage_TreeItem): void; + downloadServerDeck(deckId: number, response: Response_DeckDownload): void; createServerDeckDir(path: string, dirName: string): void; deleteServerDeckDir(path: string): void; - replayList(matchList: Data.ServerInfo_ReplayMatch[]): void; - replayAdded(matchInfo: Data.ServerInfo_ReplayMatch): void; + replayList(matchList: ServerInfo_ReplayMatch[]): void; + replayAdded(matchInfo: ServerInfo_ReplayMatch): void; replayModifyMatch(gameId: number, doNotHide: boolean): void; replayDeleteMatch(gameId: number): void; - replayDownloaded(replayId: number, response: Data.Response_ReplayDownload): void; + replayDownloaded(replayId: number, response: Response_ReplayDownload): void; } -export interface IRoomResponse { +export interface IRoomResponse { clearStore(): void; - joinRoom(roomInfo: Data.ServerInfo_Room): void; + joinRoom(roomInfo: ServerInfo_Room): void; leaveRoom(roomId: number): void; - updateRooms(rooms: Data.ServerInfo_Room[]): void; - updateGames(roomId: number, gameList: Data.ServerInfo_Game[]): void; - addMessage(roomId: number, message: Enriched.Message): void; - userJoined(roomId: number, user: Data.ServerInfo_User): void; + updateRooms(rooms: ServerInfo_Room[]): void; + updateGames(roomId: number, gameList: ServerInfo_Game[]): void; + addMessage(roomId: number, message: T[KeyOf]): void; + userJoined(roomId: number, user: ServerInfo_User): void; userLeft(roomId: number, name: string): void; removeMessages(roomId: number, name: string, amount: number): void; gameCreated(roomId: number): void; @@ -76,35 +127,35 @@ export interface IRoomResponse { export interface IGameResponse { clearStore(): void; - gameStateChanged(gameId: number, data: Data.Event_GameStateChanged): void; - playerJoined(gameId: number, playerProperties: Data.ServerInfo_PlayerProperties): void; + gameStateChanged(gameId: number, data: Event_GameStateChanged): void; + playerJoined(gameId: number, playerProperties: ServerInfo_PlayerProperties): void; playerLeft(gameId: number, playerId: number, reason: number): void; - playerPropertiesChanged(gameId: number, playerId: number, properties: Data.ServerInfo_PlayerProperties): void; + playerPropertiesChanged(gameId: number, playerId: number, properties: ServerInfo_PlayerProperties): void; gameClosed(gameId: number): void; gameHostChanged(gameId: number, hostId: number): void; kicked(gameId: number): void; gameSay(gameId: number, playerId: number, message: string): void; - cardMoved(gameId: number, playerId: number, data: Data.Event_MoveCard): void; - cardFlipped(gameId: number, playerId: number, data: Data.Event_FlipCard): void; - cardDestroyed(gameId: number, playerId: number, data: Data.Event_DestroyCard): void; - cardAttached(gameId: number, playerId: number, data: Data.Event_AttachCard): void; - tokenCreated(gameId: number, playerId: number, data: Data.Event_CreateToken): void; - cardAttrChanged(gameId: number, playerId: number, data: Data.Event_SetCardAttr): void; - cardCounterChanged(gameId: number, playerId: number, data: Data.Event_SetCardCounter): void; - arrowCreated(gameId: number, playerId: number, data: Data.Event_CreateArrow): void; - arrowDeleted(gameId: number, playerId: number, data: Data.Event_DeleteArrow): void; - counterCreated(gameId: number, playerId: number, data: Data.Event_CreateCounter): void; - counterSet(gameId: number, playerId: number, data: Data.Event_SetCounter): void; - counterDeleted(gameId: number, playerId: number, data: Data.Event_DelCounter): void; - cardsDrawn(gameId: number, playerId: number, data: Data.Event_DrawCards): void; - cardsRevealed(gameId: number, playerId: number, data: Data.Event_RevealCards): void; - zoneShuffled(gameId: number, playerId: number, data: Data.Event_Shuffle): void; - dieRolled(gameId: number, playerId: number, data: Data.Event_RollDie): void; + cardMoved(gameId: number, playerId: number, data: Event_MoveCard): void; + cardFlipped(gameId: number, playerId: number, data: Event_FlipCard): void; + cardDestroyed(gameId: number, playerId: number, data: Event_DestroyCard): void; + cardAttached(gameId: number, playerId: number, data: Event_AttachCard): void; + tokenCreated(gameId: number, playerId: number, data: Event_CreateToken): void; + cardAttrChanged(gameId: number, playerId: number, data: Event_SetCardAttr): void; + cardCounterChanged(gameId: number, playerId: number, data: Event_SetCardCounter): void; + arrowCreated(gameId: number, playerId: number, data: Event_CreateArrow): void; + arrowDeleted(gameId: number, playerId: number, data: Event_DeleteArrow): void; + counterCreated(gameId: number, playerId: number, data: Event_CreateCounter): void; + counterSet(gameId: number, playerId: number, data: Event_SetCounter): void; + counterDeleted(gameId: number, playerId: number, data: Event_DelCounter): void; + cardsDrawn(gameId: number, playerId: number, data: Event_DrawCards): void; + cardsRevealed(gameId: number, playerId: number, data: Event_RevealCards): void; + zoneShuffled(gameId: number, playerId: number, data: Event_Shuffle): void; + dieRolled(gameId: number, playerId: number, data: Event_RollDie): void; activePlayerSet(gameId: number, activePlayerId: number): void; activePhaseSet(gameId: number, phase: number): void; turnReversed(gameId: number, reversed: boolean): void; - zoneDumped(gameId: number, playerId: number, data: Data.Event_DumpZone): void; - zonePropertiesChanged(gameId: number, playerId: number, data: Data.Event_ChangeZoneProperties): void; + zoneDumped(gameId: number, playerId: number, data: Event_DumpZone): void; + zonePropertiesChanged(gameId: number, playerId: number, data: Event_ChangeZoneProperties): void; } export interface IAdminResponse { @@ -116,10 +167,10 @@ export interface IAdminResponse { export interface IModeratorResponse { banFromServer(userName: string): void; - banHistory(userName: string, banHistory: Data.ServerInfo_Ban[]): void; - viewLogs(logs: Data.ServerInfo_ChatMessage[]): void; - warnHistory(userName: string, warnHistory: Data.ServerInfo_Warning[]): void; - warnListOptions(warnList: Data.Response_WarnList[]): void; + banHistory(userName: string, banHistory: ServerInfo_Ban[]): void; + viewLogs(logs: ServerInfo_ChatMessage[]): void; + warnHistory(userName: string, warnHistory: ServerInfo_Warning[]): void; + warnListOptions(warnList: Response_WarnList[]): void; warnUser(userName: string): void; grantReplayAccess(replayId: number, moderatorName: string): void; forceActivateUser(usernameToActivate: string, moderatorName: string): void; @@ -127,9 +178,12 @@ export interface IModeratorResponse { updateAdminNotes(userName: string, notes: string): void; } -export interface IWebClientResponse { - session: ISessionResponse; - room: IRoomResponse; +export interface IWebClientResponse< + S extends ResponseMap = WebSocketSessionResponseOverrides, + R extends RoomEventMap = WebSocketRoomResponseOverrides, +> { + session: ISessionResponse; + room: IRoomResponse; game: IGameResponse; admin: IAdminResponse; moderator: IModeratorResponse; diff --git a/webclient/src/websocket/interfaces/WebSocketConfig.ts b/webclient/src/websocket/interfaces/WebSocketConfig.ts new file mode 100644 index 000000000..cc1dface5 --- /dev/null +++ b/webclient/src/websocket/interfaces/WebSocketConfig.ts @@ -0,0 +1,44 @@ +import type { + GameEventContext, + Response_Login, + Response, + Event_RoomSay, + ResponseMap, + RoomEventMap, +} from '@app/generated'; + +// ── KeyOf utility ──────────────────────────────────────────────────────────── +// Derives a type map key from a generated type. Allows interface methods to +// reference generated types instead of hardcoded string keys. +// +// T[KeyOf] +// ↓ resolves to ↓ +// T['Response_Login'] + +export type KeyOf = { [K in keyof Map]: Map[K] extends V ? K : never }[keyof Map]; + +// ── GameEventMeta ──────────────────────────────────────────────────────────── +// Per-container metadata passed to every game event handler alongside the +// event payload. Constructed by ProtobufService.processGameEvent from the +// GameEventContainer fields. Structurally identical to Enriched.GameEventMeta. + +export interface GameEventMeta { + gameId: number; + playerId: number; + context: GameEventContext | null; + secondsElapsed: number; + forcedByJudge: number; +} + +// ── Websocket-layer enrichments ────────────────────────────────────────────── +// Protocol-level enrichments of proto types — these are websocket concerns, +// not app concerns. Used as the DEFAULT generic on the response interfaces. + +export interface WebSocketSessionResponseOverrides extends ResponseMap { + Response_Login: Response_Login & { hashedPassword?: string }; + Response: Response & { host: string; port: string; userName: string }; +} + +export interface WebSocketRoomResponseOverrides extends RoomEventMap { + Event_RoomSay: Event_RoomSay & { timeReceived: number }; +} diff --git a/webclient/src/websocket/interfaces/index.ts b/webclient/src/websocket/interfaces/index.ts index abe347cd5..6c4459c91 100644 --- a/webclient/src/websocket/interfaces/index.ts +++ b/webclient/src/websocket/interfaces/index.ts @@ -8,6 +8,7 @@ export type { } from './WebClientResponse'; export type { + AuthRequestMap, IAuthenticationRequest, ISessionRequest, IRoomsRequest, @@ -16,3 +17,7 @@ export type { IModeratorRequest, IWebClientRequest, } from './WebClientRequest'; + +export * from './WebClientConfig'; +export * from './WebSocketConfig'; +export * from './StatusEnum'; diff --git a/webclient/src/websocket/services/ProtobufService.spec.ts b/webclient/src/websocket/services/ProtobufService.spec.ts index 098869e82..7880b9099 100644 --- a/webclient/src/websocket/services/ProtobufService.spec.ts +++ b/webclient/src/websocket/services/ProtobufService.spec.ts @@ -7,32 +7,47 @@ vi.mock('@bufbuild/protobuf', async (importOriginal) => ({ setExtension: vi.fn(), })); -vi.mock('../events', () => ({ +vi.mock('../events/game', () => ({ GameEvents: [], - RoomEvents: [], - SessionEvents: [], })); -vi.mock('../WebClient', () => ({ - __esModule: true, - default: {}, +vi.mock('../events/room', () => ({ + RoomEvents: [], +})); + +vi.mock('../events/session', () => ({ + SessionEvents: [], })); import { create, fromBinary, hasExtension, getExtension } from '@bufbuild/protobuf'; import type { GenExtension } from '@bufbuild/protobuf/codegenv2'; import { ProtobufService } from './ProtobufService'; -import { GameEvents, RoomEvents, SessionEvents } from '../events'; -import type { GameExtensionRegistry } from '../events/game'; -import type { RoomExtensionRegistry } from '../events/room'; -import type { SessionExtensionRegistry } from '../events/session'; -import { withEventRegistry } from '../../__test-utils__'; +import { GameEvents } from '../events/game'; +import { RoomEvents } from '../events/room'; +import { SessionEvents } from '../events/session'; -import { Data } from '@app/types'; +import type { + AdminCommand, + GameCommand, + GameEvent, + ModeratorCommand, + Response, + RoomCommand, + RoomEvent, + SessionCommand, + SessionEvent, +} from '@app/generated'; +import { + CommandContainerSchema, + ResponseSchema, + ServerMessageSchema, + ServerMessage_MessageType, +} from '@app/generated'; type ProtobufInternal = ProtobufService & { cmdId: number; - pendingCommands: Map void>; + pendingCommands: Map void>; processGameEvent(container: unknown, extra?: unknown): void; processRoomEvent(event: unknown): void; processSessionEvent(event: unknown): void; @@ -40,29 +55,26 @@ type ProtobufInternal = ProtobufService & { }; let mockSocket: { isOpen: ReturnType; send: ReturnType }; -let registryTeardowns: Array<() => void>; beforeEach(() => { mockSocket = { isOpen: vi.fn().mockReturnValue(true), send: vi.fn(), }; - registryTeardowns = []; -}); -afterEach(() => { - while (registryTeardowns.length > 0) { - registryTeardowns.pop()!(); - } + // Reset event registries + (GameEvents as any).length = 0; + (RoomEvents as any).length = 0; + (SessionEvents as any).length = 0; }); describe('ProtobufService', () => { // Mock extensions for send*Command tests — @bufbuild/protobuf is fully mocked so these are never invoked - const sessionExt = {} as GenExtension>; - const roomExt = {} as GenExtension>; - const gameExt = {} as GenExtension>; - const moderatorExt = {} as GenExtension>; - const adminExt = {} as GenExtension>; + const sessionExt = {} as GenExtension>; + const roomExt = {} as GenExtension>; + const gameExt = {} as GenExtension>; + const moderatorExt = {} as GenExtension>; + const adminExt = {} as GenExtension>; describe('resetCommands', () => { it('resets cmdId and pendingCommands', () => { @@ -79,7 +91,7 @@ describe('ProtobufService', () => { it('increments cmdId and stores callback', () => { const service = new ProtobufService(mockSocket); const cb = vi.fn(); - service.sendCommand(create(Data.CommandContainerSchema), cb); + service.sendCommand(create(CommandContainerSchema), cb); expect((service as ProtobufInternal).cmdId).toBe(1); expect((service as ProtobufInternal).pendingCommands.get(1)).toBe(cb); }); @@ -87,7 +99,7 @@ describe('ProtobufService', () => { it('sends encoded data when socket is OPEN', () => { const service = new ProtobufService(mockSocket); mockSocket.isOpen.mockReturnValue(true); - service.sendCommand(create(Data.CommandContainerSchema), vi.fn()); + service.sendCommand(create(CommandContainerSchema), vi.fn()); expect(mockSocket.send).toHaveBeenCalled(); }); @@ -95,7 +107,7 @@ describe('ProtobufService', () => { const service = new ProtobufService(mockSocket); mockSocket.isOpen.mockReturnValue(false); const cb = vi.fn(); - service.sendCommand(create(Data.CommandContainerSchema), cb); + service.sendCommand(create(CommandContainerSchema), cb); expect(mockSocket.send).not.toHaveBeenCalled(); expect((service as ProtobufInternal).cmdId).toBe(0); expect((service as ProtobufInternal).pendingCommands.size).toBe(0); @@ -116,9 +128,9 @@ describe('ProtobufService', () => { service.sendSessionCommand(sessionExt, {}, { onResponse: cb }); const storedCb = (service as ProtobufInternal).pendingCommands.get(1)!; - storedCb(create(Data.ResponseSchema)); + storedCb(create(ResponseSchema)); - expect(cb).toHaveBeenCalledWith(create(Data.ResponseSchema)); + expect(cb).toHaveBeenCalledWith(create(ResponseSchema)); }); it('does not throw when no callback is provided and pending command is triggered', () => { @@ -126,7 +138,7 @@ describe('ProtobufService', () => { service.sendSessionCommand(sessionExt, {}); const storedCb = (service as ProtobufInternal).pendingCommands.get(1)!; - expect(() => storedCb(create(Data.ResponseSchema))).not.toThrow(); + expect(() => storedCb(create(ResponseSchema))).not.toThrow(); }); }); @@ -143,9 +155,9 @@ describe('ProtobufService', () => { service.sendRoomCommand(42, roomExt, {}, { onResponse: cb }); const storedCb = (service as ProtobufInternal).pendingCommands.get(1)!; - storedCb(create(Data.ResponseSchema)); + storedCb(create(ResponseSchema)); - expect(cb).toHaveBeenCalledWith(create(Data.ResponseSchema)); + expect(cb).toHaveBeenCalledWith(create(ResponseSchema)); }); it('does not throw when no callback is provided and pending command is triggered', () => { @@ -153,7 +165,7 @@ describe('ProtobufService', () => { service.sendRoomCommand(42, roomExt, {}); const storedCb = (service as ProtobufInternal).pendingCommands.get(1)!; - expect(() => storedCb(create(Data.ResponseSchema))).not.toThrow(); + expect(() => storedCb(create(ResponseSchema))).not.toThrow(); }); }); @@ -170,9 +182,9 @@ describe('ProtobufService', () => { service.sendGameCommand(7, gameExt, {}, { onResponse: cb }); const storedCb = (service as ProtobufInternal).pendingCommands.get(1)!; - storedCb(create(Data.ResponseSchema)); + storedCb(create(ResponseSchema)); - expect(cb).toHaveBeenCalledWith(create(Data.ResponseSchema)); + expect(cb).toHaveBeenCalledWith(create(ResponseSchema)); }); it('does not throw when no callback is provided and pending command is triggered', () => { @@ -180,7 +192,7 @@ describe('ProtobufService', () => { service.sendGameCommand(7, gameExt, {}); const storedCb = (service as ProtobufInternal).pendingCommands.get(1)!; - expect(() => storedCb(create(Data.ResponseSchema))).not.toThrow(); + expect(() => storedCb(create(ResponseSchema))).not.toThrow(); }); }); @@ -197,9 +209,9 @@ describe('ProtobufService', () => { service.sendModeratorCommand(moderatorExt, {}, { onResponse: cb }); const storedCb = (service as ProtobufInternal).pendingCommands.get(1)!; - storedCb(create(Data.ResponseSchema)); + storedCb(create(ResponseSchema)); - expect(cb).toHaveBeenCalledWith(create(Data.ResponseSchema)); + expect(cb).toHaveBeenCalledWith(create(ResponseSchema)); }); it('does not throw when no callback is provided and pending command is triggered', () => { @@ -207,7 +219,7 @@ describe('ProtobufService', () => { service.sendModeratorCommand(moderatorExt, {}); const storedCb = (service as ProtobufInternal).pendingCommands.get(1)!; - expect(() => storedCb(create(Data.ResponseSchema))).not.toThrow(); + expect(() => storedCb(create(ResponseSchema))).not.toThrow(); }); }); @@ -224,9 +236,9 @@ describe('ProtobufService', () => { service.sendAdminCommand(adminExt, {}, { onResponse: cb }); const storedCb = (service as ProtobufInternal).pendingCommands.get(1)!; - storedCb(create(Data.ResponseSchema)); + storedCb(create(ResponseSchema)); - expect(cb).toHaveBeenCalledWith(create(Data.ResponseSchema)); + expect(cb).toHaveBeenCalledWith(create(ResponseSchema)); }); it('does not throw when no callback is provided and pending command is triggered', () => { @@ -234,7 +246,7 @@ describe('ProtobufService', () => { service.sendAdminCommand(adminExt, {}); const storedCb = (service as ProtobufInternal).pendingCommands.get(1)!; - expect(() => storedCb(create(Data.ResponseSchema))).not.toThrow(); + expect(() => storedCb(create(ResponseSchema))).not.toThrow(); }); }); @@ -246,9 +258,9 @@ describe('ProtobufService', () => { (service as ProtobufInternal).pendingCommands.set(1, cb); vi.mocked(fromBinary).mockReturnValue( - create(Data.ServerMessageSchema, { - messageType: Data.ServerMessage_MessageType.RESPONSE, - response: create(Data.ResponseSchema, { cmdId: BigInt(1) }), + create(ServerMessageSchema, { + messageType: ServerMessage_MessageType.RESPONSE, + response: create(ResponseSchema, { cmdId: BigInt(1) }), }) ); @@ -262,8 +274,8 @@ describe('ProtobufService', () => { const processRoomEvent = vi.spyOn(service as ProtobufInternal, 'processRoomEvent'); vi.mocked(fromBinary).mockReturnValue( - create(Data.ServerMessageSchema, { - messageType: Data.ServerMessage_MessageType.ROOM_EVENT, + create(ServerMessageSchema, { + messageType: ServerMessage_MessageType.ROOM_EVENT, }) ); @@ -276,8 +288,8 @@ describe('ProtobufService', () => { const processSessionEvent = vi.spyOn(service as ProtobufInternal, 'processSessionEvent'); vi.mocked(fromBinary).mockReturnValue( - create(Data.ServerMessageSchema, { - messageType: Data.ServerMessage_MessageType.SESSION_EVENT, + create(ServerMessageSchema, { + messageType: ServerMessage_MessageType.SESSION_EVENT, }) ); @@ -290,8 +302,8 @@ describe('ProtobufService', () => { const processGameEvent = vi.spyOn(service as ProtobufInternal, 'processGameEvent'); vi.mocked(fromBinary).mockReturnValue( - create(Data.ServerMessageSchema, { - messageType: Data.ServerMessage_MessageType.GAME_EVENT_CONTAINER, + create(ServerMessageSchema, { + messageType: ServerMessage_MessageType.GAME_EVENT_CONTAINER, }) ); @@ -304,7 +316,7 @@ describe('ProtobufService', () => { const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); vi.mocked(fromBinary).mockReturnValue( - create(Data.ServerMessageSchema, { + create(ServerMessageSchema, { messageType: 999, }) ); @@ -341,12 +353,12 @@ describe('ProtobufService', () => { }); it('dispatches to a GameEvents handler when hasExtension returns true', () => { - const service = new ProtobufService(mockSocket); const handler = vi.fn(); - const mockExt = {} as GenExtension; + const mockExt = {} as GenExtension; const payload = { someData: 1 }; - registryTeardowns.push(withEventRegistry(GameEvents as GameExtensionRegistry, [mockExt, handler])); + (GameEvents as any).push([mockExt, handler]); + const service = new ProtobufService(mockSocket); vi.mocked(hasExtension).mockReturnValue(true); vi.mocked(getExtension).mockReturnValue(payload); @@ -359,12 +371,12 @@ describe('ProtobufService', () => { }); it('defaults gameId and playerId to -1 when undefined', () => { - const service = new ProtobufService(mockSocket); const handler = vi.fn(); - const mockExt = {} as GenExtension; + const mockExt = {} as GenExtension; const payload = { someData: 1 }; - registryTeardowns.push(withEventRegistry(GameEvents as GameExtensionRegistry, [mockExt, handler])); + (GameEvents as any).push([mockExt, handler]); + const service = new ProtobufService(mockSocket); vi.mocked(hasExtension).mockReturnValue(true); vi.mocked(getExtension).mockReturnValue(payload); @@ -395,12 +407,12 @@ describe('ProtobufService', () => { }); it('dispatches to a RoomEvents handler when hasExtension returns true', () => { - const service = new ProtobufService(mockSocket); const handler = vi.fn(); - const mockExt = {} as GenExtension; + const mockExt = {} as GenExtension; const payload = { roomData: 1 }; - registryTeardowns.push(withEventRegistry(RoomEvents as RoomExtensionRegistry, [mockExt, handler])); + (RoomEvents as any).push([mockExt, handler]); + const service = new ProtobufService(mockSocket); vi.mocked(hasExtension).mockReturnValue(true); vi.mocked(getExtension).mockReturnValue(payload); @@ -420,12 +432,12 @@ describe('ProtobufService', () => { }); it('dispatches to a SessionEvents handler when hasExtension returns true', () => { - const service = new ProtobufService(mockSocket); const handler = vi.fn(); - const mockExt = {} as GenExtension; + const mockExt = {} as GenExtension; const payload = { sessionData: 1 }; - registryTeardowns.push(withEventRegistry(SessionEvents as SessionExtensionRegistry, [mockExt, handler])); + (SessionEvents as any).push([mockExt, handler]); + const service = new ProtobufService(mockSocket); vi.mocked(hasExtension).mockReturnValue(true); vi.mocked(getExtension).mockReturnValue(payload); @@ -436,3 +448,72 @@ describe('ProtobufService', () => { }); }); + +// ── Real protobuf round-trip test ───────────────────────────────────────────── +// This describe block does NOT mock @bufbuild/protobuf so it exercises real +// binary serialization. It proves that the schemas ProtobufService uses +// survive a toBinary → fromBinary cycle without data loss. +describe('ProtobufService protobuf round-trip (real @bufbuild/protobuf)', () => { + it('CommandContainer round-trips cmdId through toBinary → fromBinary', async () => { + const { create, toBinary, fromBinary: realFromBinary } = + await vi.importActual('@bufbuild/protobuf'); + const { CommandContainerSchema } = + await vi.importActual('@app/generated'); + + const original = create(CommandContainerSchema, { cmdId: BigInt(42) }); + const bytes = toBinary(CommandContainerSchema, original); + const decoded = realFromBinary(CommandContainerSchema, bytes); + + expect(decoded.cmdId).toBe(BigInt(42)); + }); + + it('ServerMessage RESPONSE round-trips with cmdId and responseCode', async () => { + const { create, toBinary, fromBinary: realFromBinary } = + await vi.importActual('@bufbuild/protobuf'); + const { ServerMessageSchema, ServerMessage_MessageType, ResponseSchema, Response_ResponseCode } = + await vi.importActual('@app/generated'); + + const response = create(ResponseSchema, { + cmdId: BigInt(7), + responseCode: Response_ResponseCode.RespOk, + }); + const msg = create(ServerMessageSchema, { + messageType: ServerMessage_MessageType.RESPONSE, + response, + }); + + const bytes = toBinary(ServerMessageSchema, msg); + const decoded = realFromBinary(ServerMessageSchema, bytes); + + expect(decoded.messageType).toBe(ServerMessage_MessageType.RESPONSE); + expect(decoded.response?.cmdId).toBe(BigInt(7)); + expect(decoded.response?.responseCode).toBe(Response_ResponseCode.RespOk); + }); + + it('SessionCommand with extension round-trips through CommandContainer', async () => { + const { create, toBinary, fromBinary: realFromBinary, setExtension, getExtension: realGetExtension } = + await vi.importActual('@bufbuild/protobuf'); + const { + CommandContainerSchema, SessionCommandSchema, + Command_Ping_ext, Command_PingSchema, + } = await vi.importActual('@app/generated'); + + const pingCmd = create(Command_PingSchema, {}); + const sesCmd = create(SessionCommandSchema, {}); + setExtension(sesCmd, Command_Ping_ext, pingCmd); + + const container = create(CommandContainerSchema, { + cmdId: BigInt(1), + sessionCommand: [sesCmd], + }); + + const bytes = toBinary(CommandContainerSchema, container); + const decoded = realFromBinary(CommandContainerSchema, bytes); + + expect(decoded.cmdId).toBe(BigInt(1)); + expect(decoded.sessionCommand).toHaveLength(1); + + const decodedPing = realGetExtension(decoded.sessionCommand[0], Command_Ping_ext); + expect(decodedPing).toBeDefined(); + }); +}); diff --git a/webclient/src/websocket/services/ProtobufService.ts b/webclient/src/websocket/services/ProtobufService.ts index c4b6cd3ce..05437f80e 100644 --- a/webclient/src/websocket/services/ProtobufService.ts +++ b/webclient/src/websocket/services/ProtobufService.ts @@ -1,11 +1,32 @@ import { create, fromBinary, hasExtension, getExtension, setExtension, toBinary } from '@bufbuild/protobuf'; import type { GenExtension } from '@bufbuild/protobuf/codegenv2'; +import { + CommandContainerSchema, + GameCommandSchema, + SessionCommandSchema, + RoomCommandSchema, + ModeratorCommandSchema, + AdminCommandSchema, + ServerMessageSchema, + ServerMessage_MessageType, + type Response, + type CommandContainer, + type GameCommand, + type SessionCommand, + type RoomCommand, + type ModeratorCommand, + type AdminCommand, + type ServerMessage, + type GameEventContainer, + type SessionEvent, + type RoomEvent, +} from '@app/generated'; -import { GameEvents, RoomEvents, SessionEvents } from '../events'; -import { Data, Enriched } from '@app/types'; - - +import { GameEvents } from '../events/game'; +import { RoomEvents } from '../events/room'; +import { SessionEvents } from '../events/session'; +import type { GameEventMeta } from '../interfaces/WebSocketConfig'; import { type CommandOptions, handleResponse } from './command-options'; export interface SocketTransport { @@ -15,13 +36,9 @@ export interface SocketTransport { export class ProtobufService { private cmdId = 0; - private pendingCommands = new Map void>(); + private pendingCommands = new Map void>(); - private transport: SocketTransport; - - constructor(transport: SocketTransport) { - this.transport = transport; - } + constructor(private transport: SocketTransport) {} public resetCommands() { this.cmdId = 0; @@ -30,13 +47,13 @@ export class ProtobufService { public sendGameCommand( gameId: number, - ext: GenExtension, + ext: GenExtension, value: V, options?: CommandOptions ): void { - const gameCmd = create(Data.GameCommandSchema); + const gameCmd = create(GameCommandSchema); setExtension(gameCmd, ext, value); - const cmd = create(Data.CommandContainerSchema, { gameId, gameCommand: [gameCmd] }); + const cmd = create(CommandContainerSchema, { gameId, gameCommand: [gameCmd] }); this.sendCommand(cmd, raw => { if (options) { handleResponse(ext.typeName, raw, options); @@ -46,13 +63,13 @@ export class ProtobufService { public sendRoomCommand( roomId: number, - ext: GenExtension, + ext: GenExtension, value: V, options?: CommandOptions ): void { - const roomCmd = create(Data.RoomCommandSchema); + const roomCmd = create(RoomCommandSchema); setExtension(roomCmd, ext, value); - const cmd = create(Data.CommandContainerSchema, { roomId, roomCommand: [roomCmd] }); + const cmd = create(CommandContainerSchema, { roomId, roomCommand: [roomCmd] }); this.sendCommand(cmd, raw => { if (options) { handleResponse(ext.typeName, raw, options); @@ -61,13 +78,13 @@ export class ProtobufService { } public sendSessionCommand( - ext: GenExtension, + ext: GenExtension, value: V, options?: CommandOptions ): void { - const sesCmd = create(Data.SessionCommandSchema); + const sesCmd = create(SessionCommandSchema); setExtension(sesCmd, ext, value); - const cmd = create(Data.CommandContainerSchema, { sessionCommand: [sesCmd] }); + const cmd = create(CommandContainerSchema, { sessionCommand: [sesCmd] }); this.sendCommand(cmd, raw => { if (options) { handleResponse(ext.typeName, raw, options); @@ -76,13 +93,13 @@ export class ProtobufService { } public sendModeratorCommand( - ext: GenExtension, + ext: GenExtension, value: V, options?: CommandOptions ): void { - const modCmd = create(Data.ModeratorCommandSchema); + const modCmd = create(ModeratorCommandSchema); setExtension(modCmd, ext, value); - const cmd = create(Data.CommandContainerSchema, { moderatorCommand: [modCmd] }); + const cmd = create(CommandContainerSchema, { moderatorCommand: [modCmd] }); this.sendCommand(cmd, raw => { if (options) { handleResponse(ext.typeName, raw, options); @@ -91,13 +108,13 @@ export class ProtobufService { } public sendAdminCommand( - ext: GenExtension, + ext: GenExtension, value: V, options?: CommandOptions ): void { - const adminCmd = create(Data.AdminCommandSchema); + const adminCmd = create(AdminCommandSchema); setExtension(adminCmd, ext, value); - const cmd = create(Data.CommandContainerSchema, { adminCommand: [adminCmd] }); + const cmd = create(CommandContainerSchema, { adminCommand: [adminCmd] }); this.sendCommand(cmd, raw => { if (options) { handleResponse(ext.typeName, raw, options); @@ -105,7 +122,7 @@ export class ProtobufService { }); } - public sendCommand(cmd: Data.CommandContainer, callback: (raw: Data.Response) => void) { + public sendCommand(cmd: CommandContainer, callback: (raw: Response) => void) { if (!this.transport.isOpen()) { return; } @@ -113,26 +130,26 @@ export class ProtobufService { this.cmdId++; cmd.cmdId = BigInt(this.cmdId); this.pendingCommands.set(this.cmdId, callback); - this.transport.send(toBinary(Data.CommandContainerSchema, cmd)); + this.transport.send(toBinary(CommandContainerSchema, cmd)); } public handleMessageEvent({ data }: MessageEvent): void { try { const uint8msg = new Uint8Array(data); - const msg: Data.ServerMessage = fromBinary(Data.ServerMessageSchema, uint8msg); + const msg: ServerMessage = fromBinary(ServerMessageSchema, uint8msg); if (msg) { switch (msg.messageType) { - case Data.ServerMessage_MessageType.RESPONSE: + case ServerMessage_MessageType.RESPONSE: this.processServerResponse(msg.response); break; - case Data.ServerMessage_MessageType.ROOM_EVENT: + case ServerMessage_MessageType.ROOM_EVENT: this.processRoomEvent(msg.roomEvent); break; - case Data.ServerMessage_MessageType.SESSION_EVENT: + case ServerMessage_MessageType.SESSION_EVENT: this.processSessionEvent(msg.sessionEvent); break; - case Data.ServerMessage_MessageType.GAME_EVENT_CONTAINER: + case ServerMessage_MessageType.GAME_EVENT_CONTAINER: this.processGameEvent(msg.gameEventContainer); break; default: @@ -145,7 +162,7 @@ export class ProtobufService { } } - private processServerResponse(response: Data.Response | undefined) { + private processServerResponse(response: Response | undefined) { if (!response) { return; } @@ -157,7 +174,7 @@ export class ProtobufService { } } - private processRoomEvent(event: Data.RoomEvent | undefined) { + private processRoomEvent(event: RoomEvent | undefined) { if (!event) { return; } @@ -169,7 +186,7 @@ export class ProtobufService { } } - private processSessionEvent(event: Data.SessionEvent | undefined) { + private processSessionEvent(event: SessionEvent | undefined) { if (!event) { return; } @@ -181,7 +198,7 @@ export class ProtobufService { } } - private processGameEvent(container: Data.GameEventContainer | undefined): void { + private processGameEvent(container: GameEventContainer | undefined): void { if (!container?.eventList?.length) { return; } @@ -189,7 +206,7 @@ export class ProtobufService { const { gameId, context, secondsElapsed, forcedByJudge } = container; for (const event of container.eventList) { - const meta: Enriched.GameEventMeta = { + const meta: GameEventMeta = { gameId: gameId ?? -1, playerId: event.playerId ?? -1, context, @@ -207,4 +224,3 @@ export class ProtobufService { } } - diff --git a/webclient/src/websocket/services/WebSocketService.spec.ts b/webclient/src/websocket/services/WebSocketService.spec.ts index e8a25802c..c565ac74e 100644 --- a/webclient/src/websocket/services/WebSocketService.spec.ts +++ b/webclient/src/websocket/services/WebSocketService.spec.ts @@ -9,7 +9,7 @@ vi.mock('../config', () => ({ import { WebSocketService } from './WebSocketService'; import type { WebSocketServiceConfig } from './WebSocketService'; import { KeepAliveService } from './KeepAliveService'; -import { App } from '@app/types'; +import { StatusEnum } from '../interfaces/StatusEnum'; type WebSocketInternal = WebSocketService & { keepAliveService: KeepAliveService; @@ -19,11 +19,7 @@ let MockWS: Mock; let mockInstance: ReturnType['mockInstance']; let restoreWebSocket: ReturnType['restore']; let mockConfig: WebSocketServiceConfig; -let mockResponse: { - session: { - connectionFailed: Mock; - }; -}; +let mockOnConnectionFailed: Mock; let mockOnStatusChange: Mock; let locationRestores: Array<() => void>; @@ -35,16 +31,12 @@ beforeEach(() => { mockInstance = installed.mockInstance; restoreWebSocket = installed.restore; - mockResponse = { - session: { - connectionFailed: vi.fn(), - }, - }; + mockOnConnectionFailed = vi.fn(); mockOnStatusChange = vi.fn(); mockConfig = { keepAliveFn: vi.fn(), - response: mockResponse as unknown as WebSocketServiceConfig['response'], + onConnectionFailed: mockOnConnectionFailed, onStatusChange: mockOnStatusChange, }; @@ -78,7 +70,7 @@ describe('WebSocketService', () => { // trigger keepAliveService.disconnected$ (service as WebSocketInternal).keepAliveService.disconnected$.next(); expect(mockInstance.close).toHaveBeenCalled(); - expect(mockOnStatusChange).toHaveBeenCalledWith(App.StatusEnum.DISCONNECTED, 'Connection timeout'); + expect(mockOnStatusChange).toHaveBeenCalledWith(StatusEnum.DISCONNECTED, 'Connection timeout'); }); }); @@ -120,7 +112,7 @@ describe('WebSocketService', () => { it('calls onStatusChange CONNECTED on open', () => { createConnectedService(); mockInstance.onopen(); - expect(mockOnStatusChange).toHaveBeenCalledWith(App.StatusEnum.CONNECTED, 'Connected'); + expect(mockOnStatusChange).toHaveBeenCalledWith(StatusEnum.CONNECTED, 'Connected'); }); it('starts the ping loop with the keepalive interval', () => { @@ -147,14 +139,14 @@ describe('WebSocketService', () => { it('calls onStatusChange DISCONNECTED on close when not already DISCONNECTED', () => { createConnectedService(); mockInstance.onclose(); - expect(mockOnStatusChange).toHaveBeenCalledWith(App.StatusEnum.DISCONNECTED, 'Connection Closed'); + expect(mockOnStatusChange).toHaveBeenCalledWith(StatusEnum.DISCONNECTED, 'Connection Closed'); }); it('does not overwrite status if already DISCONNECTED', () => { createConnectedService(); mockInstance.onerror(); mockInstance.onclose(); - expect(mockOnStatusChange).not.toHaveBeenCalledWith(App.StatusEnum.DISCONNECTED, 'Connection Closed'); + expect(mockOnStatusChange).not.toHaveBeenCalledWith(StatusEnum.DISCONNECTED, 'Connection Closed'); }); it('ends the ping loop on close', () => { @@ -170,13 +162,13 @@ describe('WebSocketService', () => { it('calls onStatusChange DISCONNECTED on error', () => { createConnectedService(); mockInstance.onerror(); - expect(mockOnStatusChange).toHaveBeenCalledWith(App.StatusEnum.DISCONNECTED, 'Connection Failed'); + expect(mockOnStatusChange).toHaveBeenCalledWith(StatusEnum.DISCONNECTED, 'Connection Failed'); }); - it('calls response.session.connectionFailed on error', () => { + it('calls onConnectionFailed on error', () => { createConnectedService(); mockInstance.onerror(); - expect(mockResponse.session.connectionFailed).toHaveBeenCalled(); + expect(mockOnConnectionFailed).toHaveBeenCalled(); }); }); diff --git a/webclient/src/websocket/services/WebSocketService.ts b/webclient/src/websocket/services/WebSocketService.ts index 3e93ad19e..cf2321667 100644 --- a/webclient/src/websocket/services/WebSocketService.ts +++ b/webclient/src/websocket/services/WebSocketService.ts @@ -1,22 +1,20 @@ import { Subject } from 'rxjs'; -import { App, Enriched } from '@app/types'; - +import { StatusEnum } from '../interfaces/StatusEnum'; import { KeepAliveService } from './KeepAliveService'; import { CLIENT_OPTIONS } from '../config'; -import { IWebClientResponse } from '../interfaces'; +import type { ConnectTarget } from '../interfaces/WebClientConfig'; export interface WebSocketServiceConfig { keepAliveFn: (pingReceived: () => void) => void; - response: IWebClientResponse; - onStatusChange: (status: App.StatusEnum, description: string) => void; + onStatusChange: (status: StatusEnum, description: string) => void; + onConnectionFailed: () => void; } export class WebSocketService { private socket: WebSocket; private config: WebSocketServiceConfig; - private response: IWebClientResponse; private keepAliveService: KeepAliveService; private errorFired = false; @@ -26,21 +24,20 @@ export class WebSocketService { constructor(config: WebSocketServiceConfig) { this.config = config; - this.response = config.response; this.keepAliveService = new KeepAliveService(() => this.checkReadyState(WebSocket.OPEN)); this.keepAliveService.disconnected$.subscribe(() => { this.disconnect(); - this.config.onStatusChange(App.StatusEnum.DISCONNECTED, 'Connection timeout'); + this.config.onStatusChange(StatusEnum.DISCONNECTED, 'Connection timeout'); }); } - public connect(options: Enriched.WebSocketConnectOptions, protocol: string = 'wss'): void { + public connect(target: ConnectTarget, protocol: string = 'wss'): void { if (window.location.hostname === 'localhost') { protocol = 'ws'; } - const { host, port } = options; + const { host, port } = target; this.keepalive = CLIENT_OPTIONS.keepalive; this.socket = this.createWebSocket(`${protocol}://${host}:${port}`); @@ -69,7 +66,7 @@ export class WebSocketService { socket.onopen = () => { clearTimeout(connectionTimer); this.errorFired = false; - this.config.onStatusChange(App.StatusEnum.CONNECTED, 'Connected'); + this.config.onStatusChange(StatusEnum.CONNECTED, 'Connected'); this.keepAliveService.startPingLoop(this.keepalive, (pingReceived: () => void) => { this.config.keepAliveFn(pingReceived); @@ -79,7 +76,7 @@ export class WebSocketService { socket.onclose = () => { // dont overwrite failure messages if (!this.errorFired) { - this.config.onStatusChange(App.StatusEnum.DISCONNECTED, 'Connection Closed'); + this.config.onStatusChange(StatusEnum.DISCONNECTED, 'Connection Closed'); } this.errorFired = false; this.keepAliveService.endPingLoop(); @@ -87,8 +84,8 @@ export class WebSocketService { socket.onerror = () => { this.errorFired = true; - this.config.onStatusChange(App.StatusEnum.DISCONNECTED, 'Connection Failed'); - this.response.session.connectionFailed(); + this.config.onStatusChange(StatusEnum.DISCONNECTED, 'Connection Failed'); + this.config.onConnectionFailed(); }; socket.onmessage = (event: MessageEvent) => { diff --git a/webclient/src/websocket/services/command-options.spec.ts b/webclient/src/websocket/services/command-options.spec.ts index 23065d8e6..19dd644be 100644 --- a/webclient/src/websocket/services/command-options.spec.ts +++ b/webclient/src/websocket/services/command-options.spec.ts @@ -1,5 +1,6 @@ import type { GenExtension } from '@bufbuild/protobuf/codegenv2'; -import { Data } from '@app/types'; +import type { Response } from '@app/generated'; +import { Response_ResponseCode, ResponseSchema } from '@app/generated'; vi.mock('@bufbuild/protobuf', async () => { const actual = await vi.importActual('@bufbuild/protobuf'); return { ...actual, getExtension: vi.fn() }; @@ -19,14 +20,14 @@ describe('handleResponse', () => { it('calls onResponse and returns early when provided', () => { const onResponse = vi.fn(); const onSuccess = vi.fn(); - handleResponse('test', create(Data.ResponseSchema, { responseCode: 99 }), { onResponse, onSuccess }); + handleResponse('test', create(ResponseSchema, { responseCode: 99 }), { onResponse, onSuccess }); expect(onResponse).toHaveBeenCalled(); expect(onSuccess).not.toHaveBeenCalled(); }); it('calls onSuccess when responseCode is RespOk and no responseExt', () => { const onSuccess = vi.fn(); - const raw = create(Data.ResponseSchema, { responseCode: Data.Response_ResponseCode.RespOk }); + const raw = create(ResponseSchema, { responseCode: Response_ResponseCode.RespOk }); handleResponse('test', raw, { onSuccess }); expect(onSuccess).toHaveBeenCalledWith(); }); @@ -34,28 +35,28 @@ describe('handleResponse', () => { it('calls onSuccess with nested response when responseExt is set', () => { vi.mocked(getExtension).mockReturnValue({ nested: true }); const onSuccess = vi.fn(); - const fakeExt = {} as unknown as GenExtension; - const raw = create(Data.ResponseSchema, { responseCode: Data.Response_ResponseCode.RespOk }); + const fakeExt = {} as unknown as GenExtension; + const raw = create(ResponseSchema, { responseCode: Response_ResponseCode.RespOk }); handleResponse('test', raw, { onSuccess, responseExt: fakeExt }); expect(onSuccess).toHaveBeenCalledWith({ nested: true }, raw); }); it('calls onResponseCode handler when code matches', () => { const specificHandler = vi.fn(); - handleResponse('test', create(Data.ResponseSchema, { responseCode: 5 }), { onResponseCode: { 5: specificHandler } }); + handleResponse('test', create(ResponseSchema, { responseCode: 5 }), { onResponseCode: { 5: specificHandler } }); expect(specificHandler).toHaveBeenCalled(); }); it('calls onError when responseCode is not RespOk and no specific handler', () => { const onError = vi.fn(); - const raw = create(Data.ResponseSchema, { responseCode: 99 }); + const raw = create(ResponseSchema, { responseCode: 99 }); handleResponse('test', raw, { onError }); expect(onError).toHaveBeenCalledWith(99, raw); }); it('logs error to console when no callbacks for non-RespOk response', () => { const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - handleResponse('test.Type', create(Data.ResponseSchema, { responseCode: 42 }), {}); + handleResponse('test.Type', create(ResponseSchema, { responseCode: 42 }), {}); expect(consoleSpy).toHaveBeenCalled(); consoleSpy.mockRestore(); }); diff --git a/webclient/src/websocket/services/command-options.ts b/webclient/src/websocket/services/command-options.ts index 671d0a02b..6d1138886 100644 --- a/webclient/src/websocket/services/command-options.ts +++ b/webclient/src/websocket/services/command-options.ts @@ -1,16 +1,16 @@ import { getExtension } from '@bufbuild/protobuf'; import type { GenExtension } from '@bufbuild/protobuf/codegenv2'; -import { Data } from '@app/types'; +import { Response_ResponseCode, type Response } from '@app/generated'; interface CommandOptionsBase { - onError?: (responseCode: number, raw: Data.Response) => void; - onResponseCode?: { [code: number]: (raw: Data.Response) => void }; - onResponse?: (raw: Data.Response) => void; + onError?: (responseCode: number, raw: Response) => void; + onResponseCode?: { [code: number]: (raw: Response) => void }; + onResponse?: (raw: Response) => void; } export interface CommandOptionsWithResponse extends CommandOptionsBase { - responseExt: GenExtension; - onSuccess?: (response: R, raw: Data.Response) => void; + responseExt: GenExtension; + onSuccess?: (response: R, raw: Response) => void; } export interface CommandOptionsWithoutResponse extends CommandOptionsBase { @@ -24,7 +24,7 @@ export function hasResponseExt(options: CommandOptions): options is Comman return options.responseExt !== undefined; } -export function handleResponse(typeName: string, raw: Data.Response, options: CommandOptions): void { +export function handleResponse(typeName: string, raw: Response, options: CommandOptions): void { if (options.onResponse) { options.onResponse(raw); return; @@ -32,7 +32,7 @@ export function handleResponse(typeName: string, raw: Data.Response, options: const { responseCode } = raw; - if (responseCode === Data.Response_ResponseCode.RespOk) { + if (responseCode === Response_ResponseCode.RespOk) { if (hasResponseExt(options)) { options.onSuccess?.(getExtension(raw, options.responseExt), raw); } else { diff --git a/webclient/src/websocket/utils/connectionState.ts b/webclient/src/websocket/utils/connectionState.ts new file mode 100644 index 000000000..de4866462 --- /dev/null +++ b/webclient/src/websocket/utils/connectionState.ts @@ -0,0 +1,13 @@ +import type { WebSocketConnectOptions } from '../interfaces/ConnectOptions'; + +let pendingOptions: WebSocketConnectOptions | null = null; + +export function setPendingOptions(options: WebSocketConnectOptions) { + pendingOptions = options; +} + +export function consumePendingOptions(): WebSocketConnectOptions | null { + const opts = pendingOptions; + pendingOptions = null; + return opts; +} diff --git a/webclient/src/websocket/utils/passwordHasher.ts b/webclient/src/websocket/utils/passwordHasher.ts index 22951ce49..6a230d9ea 100644 --- a/webclient/src/websocket/utils/passwordHasher.ts +++ b/webclient/src/websocket/utils/passwordHasher.ts @@ -1,6 +1,6 @@ import sha512 from 'crypto-js/sha512'; import Base64 from 'crypto-js/enc-base64'; -import { Data } from '@app/types'; +import { Event_ServerIdentification_ServerOptions } from '@app/generated'; const HASH_ROUNDS = 1_000; const SALT_LENGTH = 16; @@ -28,5 +28,5 @@ export const generateSalt = (): string => { export const passwordSaltSupported = (serverOptions: number): number => { // Intentional use of Bitwise operator b/c of how Servatrice Enums work - return serverOptions & Data.Event_ServerIdentification_ServerOptions.SupportsPasswordHash; + return serverOptions & Event_ServerIdentification_ServerOptions.SupportsPasswordHash; } diff --git a/webclient/vite.config.ts b/webclient/vite.config.ts index 86c243058..1c1157f0a 100644 --- a/webclient/vite.config.ts +++ b/webclient/vite.config.ts @@ -18,7 +18,7 @@ export default defineConfig({ environment: 'jsdom', setupFiles: ['./src/setupTests.ts'], include: ['src/**/*.spec.{ts,tsx}'], - isolate: false, + isolate: true, coverage: { provider: 'v8', reporter: ['text', 'html'], diff --git a/webclient/vitest.integration.config.ts b/webclient/vitest.integration.config.ts index dfba71824..0b88f927a 100644 --- a/webclient/vitest.integration.config.ts +++ b/webclient/vitest.integration.config.ts @@ -16,12 +16,15 @@ export default defineConfig({ environment: 'jsdom', setupFiles: ['./integration/src/helpers/setup.ts'], include: ['integration/src/**/*.spec.ts'], - isolate: false, coverage: { provider: 'v8', reporter: ['text', 'html'], reportsDirectory: './coverage/integration', - include: ['src/**/*.{ts,tsx}'], + include: [ + 'src/websocket/**/*.{ts,tsx}', + 'src/store/**/*.{ts,tsx}', + 'src/api/**/*.{ts,tsx}', + ], exclude: [ 'src/generated/**', 'src/**/*.spec.{ts,tsx}',