diff --git a/webclient/eslint.boundaries.mjs b/webclient/eslint.boundaries.mjs deleted file mode 100644 index 0d67c6ae9..000000000 --- a/webclient/eslint.boundaries.mjs +++ /dev/null @@ -1,55 +0,0 @@ -import boundaries from 'eslint-plugin-boundaries'; - -const elements = [ - { type: 'api', pattern: ['src/api/**'] }, - { type: 'components', pattern: ['src/components/**'] }, - { type: 'containers', pattern: ['src/containers/**'] }, - { type: 'dialogs', pattern: ['src/dialogs/**'] }, - { type: 'forms', pattern: ['src/forms/**'] }, - { type: 'generated', pattern: ['src/generated/**'] }, - { type: 'hooks', pattern: ['src/hooks/**'] }, - { type: 'images', pattern: ['src/images/**'] }, - { type: 'services', pattern: ['src/services/**'] }, - { type: 'store', pattern: ['src/store/**'] }, - { type: 'types', pattern: ['src/types/**'] }, - { type: 'websocket', pattern: ['src/websocket/**'] }, -]; - -const types = (...types) => types.map((type) => ({ to: { type } })); - -const rules = [ - { from: { type: 'generated' }, allow: [] }, - { from: { type: 'types' }, allow: types('generated') }, - - { from: { type: 'websocket' }, allow: types('types') }, - { from: { type: 'store' }, allow: types('types') }, - { from: { type: 'api' }, allow: types('types', 'store', 'websocket') }, - - { from: { type: 'hooks' }, allow: types('services', 'types') }, - { 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') }, -]; - -export const boundariesConfig = { - plugins: { boundaries }, - settings: { - 'boundaries/elements': elements, - 'import/resolver': { - typescript: { - alwaysTryTypes: true, - project: './tsconfig.json', - }, - }, - }, - rules: { - 'boundaries/dependencies': ['error', { - default: 'disallow', - rules, - }], - }, -}; diff --git a/webclient/eslint.config.mjs b/webclient/eslint.config.mjs index 28a00a0ae..f5a46a5a7 100644 --- a/webclient/eslint.config.mjs +++ b/webclient/eslint.config.mjs @@ -1,7 +1,6 @@ import js from '@eslint/js'; import tseslint from 'typescript-eslint'; import globals from 'globals'; -import { boundariesConfig } from './eslint.boundaries.mjs'; export default tseslint.config( // Global ignores @@ -13,9 +12,6 @@ export default tseslint.config( // TypeScript recommended (sets up parser + plugin) ...tseslint.configs.recommended, - // Enforce module boundaries - boundariesConfig, - // Project-specific config { languageOptions: { diff --git a/webclient/integration/src/authentication.spec.ts b/webclient/integration/src/authentication.spec.ts deleted file mode 100644 index fb852b565..000000000 --- a/webclient/integration/src/authentication.spec.ts +++ /dev/null @@ -1,136 +0,0 @@ -// Authentication scenarios — login success/failure, register, and activate. - -import { create } from '@bufbuild/protobuf'; -import { describe, expect, it } from 'vitest'; - -import { App, Data } from '@app/types'; -import { store } from '@app/store'; - -import { connectAndHandshake } from './helpers/setup'; -import { - buildResponse, - buildResponseMessage, - deliverMessage, -} from './helpers/protobuf-builders'; -import { findLastSessionCommand } from './helpers/command-capture'; - -function makeUser(name: string): Data.ServerInfo_User { - return create(Data.ServerInfo_UserSchema, { - name, - userLevel: Data.ServerInfo_User_UserLevelFlag.IsRegistered, - }); -} - -describe('authentication', () => { - describe('login', () => { - it('drives LOGIN → LOGGED_IN and populates user info + buddy/ignore lists', () => { - connectAndHandshake({ userName: 'alice' }); - - const { cmdId, value } = findLastSessionCommand(Data.Command_Login_ext); - expect(value.userName).toBe('alice'); - - const loginPayload = create(Data.Response_LoginSchema, { - userInfo: makeUser('alice'), - buddyList: [makeUser('bob')], - ignoreList: [makeUser('mallory')], - }); - deliverMessage(buildResponseMessage(buildResponse({ - cmdId, - responseCode: Data.Response_ResponseCode.RespOk, - ext: Data.Response_Login_ext, - value: loginPayload, - }))); - - const state = store.getState().server; - expect(state.status.state).toBe(App.StatusEnum.LOGGED_IN); - expect(state.status.description).toBe('Logged in.'); - expect(state.user?.name).toBe('alice'); - expect(Object.keys(state.buddyList)).toEqual(['bob']); - expect(Object.keys(state.ignoreList)).toEqual(['mallory']); - - expect(() => findLastSessionCommand(Data.Command_ListUsers_ext)).not.toThrow(); - expect(() => findLastSessionCommand(Data.Command_ListRooms_ext)).not.toThrow(); - }); - - it('flips status to DISCONNECTED on RespWrongPassword', () => { - connectAndHandshake(); - - const { cmdId } = findLastSessionCommand(Data.Command_Login_ext); - deliverMessage(buildResponseMessage(buildResponse({ - cmdId, - responseCode: Data.Response_ResponseCode.RespWrongPassword, - }))); - - const state = store.getState().server; - expect(state.status.state).toBe(App.StatusEnum.DISCONNECTED); - expect(state.user).toBeNull(); - expect(state.buddyList).toEqual({}); - }); - }); - - describe('register', () => { - const registerOptions = { - reason: App.WebSocketConnectReason.REGISTER, - host: 'localhost', - port: '4748', - userName: 'newbie', - password: 'hunter2', - email: 'newbie@example.com', - country: 'US', - realName: 'New Bie', - } as const; - - it('auto-logs-in on RespRegistrationAccepted', () => { - connectAndHandshake(registerOptions as any); - - const register = findLastSessionCommand(Data.Command_Register_ext); - expect(register.value.userName).toBe('newbie'); - - deliverMessage(buildResponseMessage(buildResponse({ - cmdId: register.cmdId, - responseCode: Data.Response_ResponseCode.RespRegistrationAccepted, - }))); - - const login = findLastSessionCommand(Data.Command_Login_ext); - expect(login.value.userName).toBe('newbie'); - expect(login.cmdId).toBeGreaterThan(register.cmdId); - }); - - it('parks registration in awaiting-activation on RespRegistrationAcceptedNeedsActivation', () => { - connectAndHandshake(registerOptions as any); - - const register = findLastSessionCommand(Data.Command_Register_ext); - deliverMessage(buildResponseMessage(buildResponse({ - cmdId: register.cmdId, - responseCode: Data.Response_ResponseCode.RespRegistrationAcceptedNeedsActivation, - }))); - - expect(store.getState().server.status.state).toBe(App.StatusEnum.DISCONNECTED); - expect(() => findLastSessionCommand(Data.Command_Login_ext)).toThrow(); - }); - }); - - describe('activate', () => { - it('auto-logs-in on RespActivationAccepted', () => { - connectAndHandshake({ - reason: App.WebSocketConnectReason.ACTIVATE_ACCOUNT, - 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'); - - deliverMessage(buildResponseMessage(buildResponse({ - cmdId: activate.cmdId, - responseCode: Data.Response_ResponseCode.RespActivationAccepted, - }))); - - const login = findLastSessionCommand(Data.Command_Login_ext); - expect(login.value.userName).toBe('alice'); - }); - }); -}); diff --git a/webclient/integration/src/connection.spec.ts b/webclient/integration/src/connection.spec.ts deleted file mode 100644 index d81c35f49..000000000 --- a/webclient/integration/src/connection.spec.ts +++ /dev/null @@ -1,108 +0,0 @@ -// Connection-lifecycle scenarios. Exercises the full transport handshake -// 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 { store } from '@app/store'; - -import { PROTOCOL_VERSION } from '../../src/websocket/config'; - -import { getMockWebSocket, getWebClient, openMockWebSocket } from './helpers/setup'; -import { - buildSessionEventMessage, - deliverMessage, -} from './helpers/protobuf-builders'; -import { findLastSessionCommand } from './helpers/command-capture'; - -function loginOptions(overrides: Partial<{ userName: string; password: string }> = {}) { - return { - reason: App.WebSocketConnectReason.LOGIN, - host: 'localhost', - port: '4748', - userName: overrides.userName ?? 'alice', - password: overrides.password ?? 'secret', - } as const; -} - -function serverIdentification( - protocolVersion = PROTOCOL_VERSION, - serverName = 'TestServer', - serverVersion = '2.8.0' -): Uint8Array { - const payload = create(Data.Event_ServerIdentificationSchema, { - serverName, - serverVersion, - protocolVersion, - serverOptions: Data.Event_ServerIdentification_ServerOptions.NoOptions, - }); - return buildSessionEventMessage(Data.Event_ServerIdentification_ext, payload); -} - -describe('connection lifecycle', () => { - it('flips status through CONNECTING → CONNECTED on socket open', () => { - getWebClient().connect(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.description).toBe('Connected'); - }); - - it('routes a matching ServerIdentification into LOGGING_IN and sends Command_Login', () => { - getWebClient().connect(loginOptions({ userName: 'alice' })); - openMockWebSocket(); - - deliverMessage(serverIdentification()); - - expect(store.getState().server.status.state).toBe(App.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()); - openMockWebSocket(); - - deliverMessage(serverIdentification(PROTOCOL_VERSION + 1)); - - const mock = getMockWebSocket(); - expect(mock.close).toHaveBeenCalled(); - expect(store.getState().server.status.state).toBe(App.StatusEnum.DISCONNECTED); - expect(() => findLastSessionCommand(Data.Command_Login_ext)).toThrow(); - }); - - it('times out when onopen never fires within the keepalive window', () => { - getWebClient().connect(loginOptions()); - - const mock = getMockWebSocket(); - expect(mock.close).not.toHaveBeenCalled(); - - vi.advanceTimersByTime(5000); - - expect(mock.close).toHaveBeenCalled(); - expect(store.getState().server.status.state).toBe(App.StatusEnum.DISCONNECTED); - }); - - it('releases keep-alive ping loop on explicit disconnect', () => { - getWebClient().connect(loginOptions()); - openMockWebSocket(); - deliverMessage(serverIdentification()); - - const mock = getMockWebSocket(); - getWebClient().disconnect(); - - expect(mock.close).toHaveBeenCalled(); - expect(store.getState().server.status.state).toBe(App.StatusEnum.DISCONNECTED); - }); -}); diff --git a/webclient/integration/src/helpers/command-capture.ts b/webclient/integration/src/helpers/command-capture.ts deleted file mode 100644 index 0b48f4df6..000000000 --- a/webclient/integration/src/helpers/command-capture.ts +++ /dev/null @@ -1,112 +0,0 @@ -// Helpers for inspecting outbound commands. WebSocketService calls -// `this.socket.send(bytes)` with the encoded CommandContainer; the mock -// WebSocket records those calls on its `send` vi.fn. These helpers decode -// the bytes back into a CommandContainer so tests can assert on what was -// sent and extract the `cmdId` needed to build a correlated response. - -import { fromBinary, getExtension, hasExtension } from '@bufbuild/protobuf'; -import type { GenExtension } from '@bufbuild/protobuf/codegenv2'; - -import { Data } from '@app/types'; - -import { getMockWebSocket } from './setup'; - -/** The three command scopes a CommandContainer can carry in practice. */ -type SessionCmd = Data.SessionCommand; -type RoomCmd = Data.RoomCommand; -type GameCmd = Data.GameCommand; - -/** Decode every CommandContainer sent through the mock socket so far. */ -export function captureAllOutbound(): Data.CommandContainer[] { - const mock = getMockWebSocket(); - return mock.send.mock.calls.map(([bytes]: [Uint8Array]) => - fromBinary(Data.CommandContainerSchema, bytes) - ); -} - -/** Decode the most recent CommandContainer. Throws if none has been sent. */ -export function captureLastOutbound(): Data.CommandContainer { - const all = captureAllOutbound(); - if (all.length === 0) { - throw new Error('No outbound command has been sent through the mock WebSocket.'); - } - return all[all.length - 1]; -} - -/** Numeric cmdId of the most recently sent command (the BigInt cast back to number). */ -export function lastCmdId(): number { - return Number(captureLastOutbound().cmdId); -} - -/** - * Find the most recently sent CommandContainer whose session-scope command - * carries the given extension, and return both the container and the - * unwrapped extension value. Handy for "the login() call fired — grab its - * cmdId and the Command_Login payload it sent". - */ -export function findLastSessionCommand( - 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 sessionCmd of container.sessionCommand ?? []) { - if (hasExtension(sessionCmd, ext)) { - return { - container, - value: getExtension(sessionCmd, ext), - cmdId: Number(container.cmdId), - }; - } - } - } - throw new Error( - `No outbound session command with extension ${ext.typeName} has been sent.` - ); -} - -/** Room-scoped equivalent of {@link findLastSessionCommand}. */ -export function findLastRoomCommand( - ext: GenExtension -): { container: Data.CommandContainer; value: V; cmdId: number; roomId: number } { - const containers = captureAllOutbound(); - for (let i = containers.length - 1; i >= 0; i--) { - const container = containers[i]; - for (const roomCmd of container.roomCommand ?? []) { - if (hasExtension(roomCmd, ext)) { - return { - container, - value: getExtension(roomCmd, ext), - cmdId: Number(container.cmdId), - roomId: container.roomId ?? 0, - }; - } - } - } - throw new Error( - `No outbound room command with extension ${ext.typeName} has been sent.` - ); -} - -/** Game-scoped equivalent of {@link findLastSessionCommand}. */ -export function findLastGameCommand( - ext: GenExtension -): { container: Data.CommandContainer; value: V; cmdId: number; gameId: number } { - const containers = captureAllOutbound(); - for (let i = containers.length - 1; i >= 0; i--) { - const container = containers[i]; - for (const gameCmd of container.gameCommand ?? []) { - if (hasExtension(gameCmd, ext)) { - return { - container, - value: getExtension(gameCmd, ext), - cmdId: Number(container.cmdId), - gameId: container.gameId ?? 0, - }; - } - } - } - throw new Error( - `No outbound game command with extension ${ext.typeName} has been sent.` - ); -} diff --git a/webclient/integration/src/helpers/protobuf-builders.ts b/webclient/integration/src/helpers/protobuf-builders.ts deleted file mode 100644 index 8081a2b87..000000000 --- a/webclient/integration/src/helpers/protobuf-builders.ts +++ /dev/null @@ -1,125 +0,0 @@ -// Factory helpers that build encoded `ServerMessage` binaries for the four -// top-level message types the client consumes (RESPONSE, SESSION_EVENT, -// ROOM_EVENT, GAME_EVENT_CONTAINER). Tests call these to simulate incoming -// server traffic and then hand the resulting bytes to `deliverMessage()`. -// -// No mocking of `@bufbuild/protobuf` — every builder uses the real `create`/ -// `setExtension`/`toBinary` path so the bytes that land in ProtobufService -// are byte-for-byte identical to what a Servatrice would send. - -import { create, setExtension, toBinary } from '@bufbuild/protobuf'; -import type { GenExtension, GenMessage } from '@bufbuild/protobuf/codegenv2'; -import type { MessageInitShape } from '@bufbuild/protobuf'; - -import { Data } from '@app/types'; - -import { getMockWebSocket } from './setup'; - -/** - * Convenience wrapper around `create` for schemas that accept an init shape. - * Mirrors the pattern used throughout the webclient codebase. - */ -export function make>( - schema: S, - init?: MessageInitShape -): ReturnType> { - return create(schema, init); -} - -/** Build a top-level ServerMessage wrapping a Response. */ -export function buildResponseMessage(response: Data.Response): Uint8Array { - const msg = create(Data.ServerMessageSchema, { - messageType: Data.ServerMessage_MessageType.RESPONSE, - response, - }); - return toBinary(Data.ServerMessageSchema, msg); -} - -/** - * Build a Response with an optional response-payload extension attached. - * `cmdId` must match the outbound command the test is responding to — - * callers typically read it from `captureOutbound()`. - */ -export function buildResponse(params: { - cmdId: number; - responseCode?: Data.Response_ResponseCode; - ext?: GenExtension; - value?: V; -}): Data.Response { - const response = create(Data.ResponseSchema, { - cmdId: BigInt(params.cmdId), - responseCode: params.responseCode ?? Data.Response_ResponseCode.RespOk, - }); - if (params.ext && params.value !== undefined) { - setExtension(response, params.ext, params.value); - } - return response; -} - -/** Build a top-level ServerMessage wrapping a SessionEvent with the given extension. */ -export function buildSessionEventMessage( - ext: GenExtension, - value: V -): Uint8Array { - const sessionEvent = create(Data.SessionEventSchema); - setExtension(sessionEvent, ext, value); - const msg = create(Data.ServerMessageSchema, { - messageType: Data.ServerMessage_MessageType.SESSION_EVENT, - sessionEvent, - }); - return toBinary(Data.ServerMessageSchema, msg); -} - -/** Build a top-level ServerMessage wrapping a RoomEvent with the given extension. */ -export function buildRoomEventMessage( - roomId: number, - ext: GenExtension, - value: V -): Uint8Array { - const roomEvent = create(Data.RoomEventSchema, { roomId }); - setExtension(roomEvent, ext, value); - const msg = create(Data.ServerMessageSchema, { - messageType: Data.ServerMessage_MessageType.ROOM_EVENT, - roomEvent, - }); - return toBinary(Data.ServerMessageSchema, msg); -} - -/** - * Build a top-level ServerMessage wrapping a GameEventContainer whose - * `eventList` contains a single GameEvent with the given extension attached. - */ -export function buildGameEventMessage( - params: { - gameId: number; - playerId?: number; - ext: GenExtension; - value: V; - } -): Uint8Array { - const gameEvent = create(Data.GameEventSchema, { - playerId: params.playerId ?? -1, - }); - setExtension(gameEvent, params.ext, params.value); - const container = create(Data.GameEventContainerSchema, { - gameId: params.gameId, - eventList: [gameEvent], - }); - const msg = create(Data.ServerMessageSchema, { - messageType: Data.ServerMessage_MessageType.GAME_EVENT_CONTAINER, - gameEventContainer: container, - }); - return toBinary(Data.ServerMessageSchema, msg); -} - -/** - * Deliver an encoded ServerMessage to the currently-connected mock socket. - * WebSocketService wires `onmessage` to push events into its RxJS subject, - * which ProtobufService subscribes to — so this triggers the full inbound - * pipeline synchronously. - */ -export function deliverMessage(binary: Uint8Array): void { - const mock = getMockWebSocket(); - const event = { data: binary.buffer } as MessageEvent; - mock.onmessage?.(event); -} diff --git a/webclient/integration/src/helpers/setup.ts b/webclient/integration/src/helpers/setup.ts deleted file mode 100644 index 99d351311..000000000 --- a/webclient/integration/src/helpers/setup.ts +++ /dev/null @@ -1,182 +0,0 @@ -// Integration test setup — installs a mock WebSocket constructor, wires up -// fake timers for KeepAliveService control, and resets the webClient + Redux -// singletons between tests so real event handlers and reducers can run -// against a clean slate each time. -// -// Only `globalThis.WebSocket` is mocked. Everything downstream of it -// (ProtobufService, event registries, persistence, store, reducers) runs as -// real code, which is the whole point of the integration suite. - -import '@testing-library/jest-dom/vitest'; -import '../../../src/polyfills'; - -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 { PROTOCOL_VERSION } from '../../../src/websocket/config'; -import { createWebClientResponse, createWebClientRequest } from '@app/api'; - -import { - buildResponse, - buildResponseMessage, - buildSessionEventMessage, - deliverMessage, -} from './protobuf-builders'; -import { findLastSessionCommand } from './command-capture'; - -export interface MockWebSocketInstance { - send: ReturnType; - close: ReturnType; - readyState: number; - binaryType: BinaryType; - url: string; - onopen: ((ev?: Event) => void) | null; - onclose: ((ev?: CloseEvent) => void) | null; - onerror: ((ev?: Event) => void) | null; - onmessage: ((ev: MessageEvent) => void) | null; -} - -let currentMockInstance: MockWebSocketInstance | null = null; - -export function getMockWebSocket(): MockWebSocketInstance { - if (!currentMockInstance) { - throw new Error( - 'No mock WebSocket has been constructed yet. Call webClient.connect(...) before reading the mock instance.' - ); - } - return currentMockInstance; -} - -function makeMockInstance(url: string): MockWebSocketInstance { - return { - send: vi.fn(), - close: vi.fn(function close(this: MockWebSocketInstance) { - this.readyState = 3; // CLOSED - this.onclose?.({ code: 1000, reason: '', wasClean: true } as CloseEvent); - }), - readyState: 0, // CONNECTING - binaryType: 'arraybuffer', - url, - onopen: null, - onclose: null, - onerror: null, - onmessage: null, - }; -} - -function installMockWebSocket(): void { - const MockWS = vi.fn(function MockWebSocket(url: string) { - currentMockInstance = makeMockInstance(url); - return currentMockInstance; - }) as unknown as typeof WebSocket; - (MockWS as unknown as { CONNECTING: number }).CONNECTING = 0; - (MockWS as unknown as { OPEN: number }).OPEN = 1; - (MockWS as unknown as { CLOSING: number }).CLOSING = 2; - (MockWS as unknown as { CLOSED: number }).CLOSED = 3; - globalThis.WebSocket = MockWS; -} - -export function openMockWebSocket(): void { - const mock = getMockWebSocket(); - mock.readyState = 1; // OPEN - mock.onopen?.(new Event('open')); -} - -export function getWebClient(): WebClient { - return WebClient.instance; -} - -function resetAll(): void { - const client = WebClient.instance; - - if (currentMockInstance && currentMockInstance.readyState === 1) { - client.disconnect(); - } - - client.protobuf.resetCommands(); - client.options = null; - client.status = App.StatusEnum.DISCONNECTED; - - ServerDispatch.clearStore(); - RoomsDispatch.clearStore(); - GameDispatch.clearStore(); - - if (currentMockInstance) { - currentMockInstance.onopen = null; - currentMockInstance.onclose = null; - currentMockInstance.onerror = null; - currentMockInstance.onmessage = null; - currentMockInstance = null; - } - - (WebClient as unknown as { _instance: WebClient | null })._instance = null; -} - -// ── Shared connect helpers ────────────────────────────────────────────────── - -const DEFAULT_LOGIN_OPTIONS: Enriched.LoginConnectOptions = { - reason: App.WebSocketConnectReason.LOGIN, - host: 'localhost', - port: '4748', - userName: 'alice', - password: 'secret', -}; - -export function connectRaw( - overrides: Partial = {} -): void { - getWebClient().connect({ ...DEFAULT_LOGIN_OPTIONS, ...overrides }); - openMockWebSocket(); -} - -export function connectAndHandshake( - overrides: Partial = {} -): void { - connectRaw(overrides); - deliverMessage(buildSessionEventMessage( - Data.Event_ServerIdentification_ext, - create(Data.Event_ServerIdentificationSchema, { - serverName: 'TestServer', - serverVersion: '2.8.0', - protocolVersion: PROTOCOL_VERSION, - }) - )); -} - -export function connectAndLogin(userName: string = 'alice'): void { - connectAndHandshake({ userName }); - - const login = findLastSessionCommand(Data.Command_Login_ext); - const userInfo = create(Data.ServerInfo_UserSchema, { - name: userName, - userLevel: Data.ServerInfo_User_UserLevelFlag.IsRegistered, - }); - deliverMessage(buildResponseMessage(buildResponse({ - cmdId: login.cmdId, - responseCode: Data.Response_ResponseCode.RespOk, - ext: Data.Response_Login_ext, - value: create(Data.Response_LoginSchema, { - userInfo, - buddyList: [], - ignoreList: [], - }), - }))); -} - -// ── Lifecycle hooks ───────────────────────────────────────────────────────── - -installMockWebSocket(); - -beforeEach(() => { - vi.useFakeTimers(); - new WebClient(createWebClientResponse(), createWebClientRequest()); -}); - -afterEach(() => { - resetAll(); - vi.clearAllMocks(); - vi.useRealTimers(); -}); diff --git a/webclient/integration/src/keep-alive.spec.ts b/webclient/integration/src/keep-alive.spec.ts deleted file mode 100644 index 521d4dce9..000000000 --- a/webclient/integration/src/keep-alive.spec.ts +++ /dev/null @@ -1,65 +0,0 @@ -// KeepAliveService timing scenarios — ping loop, pong correlation, timeout. - -import { describe, expect, it } from 'vitest'; - -import { App, Data } from '@app/types'; -import { store } from '@app/store'; - -import { connectRaw, getMockWebSocket } from './helpers/setup'; -import { - buildResponse, - buildResponseMessage, - deliverMessage, -} from './helpers/protobuf-builders'; -import { findLastSessionCommand } from './helpers/command-capture'; - -describe('keep-alive', () => { - it('sends a Command_Ping on every keepalive interval tick', () => { - connectRaw(); - - expect(() => findLastSessionCommand(Data.Command_Ping_ext)).toThrow(); - - vi.advanceTimersByTime(5000); - const first = findLastSessionCommand(Data.Command_Ping_ext); - expect(first.cmdId).toBeGreaterThan(0); - - deliverMessage(buildResponseMessage(buildResponse({ - cmdId: first.cmdId, - responseCode: Data.Response_ResponseCode.RespOk, - }))); - - 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); - }); - - it('stays CONNECTED while pongs arrive before the next tick', () => { - connectRaw(); - - for (let i = 0; i < 3; i++) { - vi.advanceTimersByTime(5000); - const ping = findLastSessionCommand(Data.Command_Ping_ext); - deliverMessage(buildResponseMessage(buildResponse({ - cmdId: ping.cmdId, - responseCode: Data.Response_ResponseCode.RespOk, - }))); - } - - expect(store.getState().server.status.state).toBe(App.StatusEnum.CONNECTED); - expect(getMockWebSocket().close).not.toHaveBeenCalled(); - }); - - it('disconnects with a timeout status when a ping goes unanswered', () => { - connectRaw(); - - vi.advanceTimersByTime(5000); - expect(() => findLastSessionCommand(Data.Command_Ping_ext)).not.toThrow(); - expect(store.getState().server.status.state).toBe(App.StatusEnum.CONNECTED); - - vi.advanceTimersByTime(5000); - - expect(getMockWebSocket().close).toHaveBeenCalled(); - expect(store.getState().server.status.state).toBe(App.StatusEnum.DISCONNECTED); - }); -}); diff --git a/webclient/integration/src/rooms.spec.ts b/webclient/integration/src/rooms.spec.ts deleted file mode 100644 index 7e9ed519f..000000000 --- a/webclient/integration/src/rooms.spec.ts +++ /dev/null @@ -1,140 +0,0 @@ -// Room scenarios — Event_ListRooms handling, auto-join, Response_JoinRoom, -// room chat, and in-room game list updates. - -import { create } from '@bufbuild/protobuf'; -import { describe, expect, it } from 'vitest'; - -import { Data } from '@app/types'; -import { store } from '@app/store'; - -import { connectAndHandshake } from './helpers/setup'; -import { - buildResponse, - buildResponseMessage, - buildRoomEventMessage, - buildSessionEventMessage, - deliverMessage, -} from './helpers/protobuf-builders'; -import { findLastSessionCommand } from './helpers/command-capture'; - -function makeRoom(overrides: Partial<{ - roomId: number; - name: string; - autoJoin: boolean; -}> = {}): Data.ServerInfo_Room { - return create(Data.ServerInfo_RoomSchema, { - roomId: overrides.roomId ?? 1, - name: overrides.name ?? 'Lobby', - description: 'Test room', - gameCount: 0, - playerCount: 0, - autoJoin: overrides.autoJoin ?? false, - gameList: [], - userList: [], - gametypeList: [], - }); -} - -describe('rooms', () => { - it('populates rooms state from Event_ListRooms', () => { - connectAndHandshake(); - - const listRooms = create(Data.Event_ListRoomsSchema, { - roomList: [ - makeRoom({ roomId: 1, name: 'Lobby' }), - makeRoom({ roomId: 2, name: 'Legacy' }), - ], - }); - deliverMessage(buildSessionEventMessage(Data.Event_ListRooms_ext, listRooms)); - - const { rooms } = store.getState().rooms; - expect(rooms[1]?.info?.name).toBe('Lobby'); - expect(rooms[2]?.info?.name).toBe('Legacy'); - }); - - it('auto-joins rooms flagged with autoJoin and flips joinedRoomIds on Response_JoinRoom', () => { - connectAndHandshake(); - - const listRooms = create(Data.Event_ListRoomsSchema, { - roomList: [ - makeRoom({ roomId: 1, name: 'Lobby', autoJoin: true }), - makeRoom({ roomId: 2, name: 'Legacy', autoJoin: false }), - ], - }); - deliverMessage(buildSessionEventMessage(Data.Event_ListRooms_ext, listRooms)); - - const join = findLastSessionCommand(Data.Command_JoinRoom_ext); - expect(join.value.roomId).toBe(1); - - const joined = create(Data.Response_JoinRoomSchema, { - roomInfo: makeRoom({ roomId: 1, name: 'Lobby' }), - }); - deliverMessage(buildResponseMessage(buildResponse({ - cmdId: join.cmdId, - responseCode: Data.Response_ResponseCode.RespOk, - ext: Data.Response_JoinRoom_ext, - value: joined, - }))); - - expect(store.getState().rooms.joinedRoomIds[1]).toBe(true); - }); - - 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 }) }), - }))); - - const say = create(Data.Event_RoomSaySchema, { - name: 'bob', - message: 'hello world', - messageType: Data.Event_RoomSay_RoomMessageType.UserMessage, - }); - deliverMessage(buildRoomEventMessage(1, Data.Event_RoomSay_ext, say)); - - const messages = store.getState().rooms.messages[1]; - expect(messages).toHaveLength(1); - expect(messages[0].message).toBe('bob: hello world'); - expect(messages[0].name).toBe('bob'); - }); - - 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 }) }), - }))); - - const game = create(Data.ServerInfo_GameSchema, { - gameId: 42, - description: 'Test Game', - maxPlayers: 4, - playerCount: 1, - startTime: 1, - }); - const listGames = create(Data.Event_ListGamesSchema, { gameList: [game] }); - deliverMessage(buildRoomEventMessage(1, Data.Event_ListGames_ext, listGames)); - - const roomGames = store.getState().rooms.rooms[1]?.games; - expect(roomGames).toBeDefined(); - expect(roomGames?.[42]?.info?.description).toBe('Test Game'); - expect(roomGames?.[42]?.info?.gameId).toBe(42); - }); -}); diff --git a/webclient/integration/src/server-events.spec.ts b/webclient/integration/src/server-events.spec.ts deleted file mode 100644 index faa42f11a..000000000 --- a/webclient/integration/src/server-events.spec.ts +++ /dev/null @@ -1,105 +0,0 @@ -// Server-level session events — server message banner, shutdown schedule, -// user notifications, and connection-closed reason code mapping. - -import { create } from '@bufbuild/protobuf'; -import { describe, expect, it } from 'vitest'; - -import { App, Data } from '@app/types'; -import { store } from '@app/store'; - -import { connectAndHandshake } from './helpers/setup'; -import { - buildSessionEventMessage, - deliverMessage, -} from './helpers/protobuf-builders'; - -describe('server events', () => { - it('writes the server banner into server.info.message on Event_ServerMessage', () => { - connectAndHandshake(); - - deliverMessage(buildSessionEventMessage( - Data.Event_ServerMessage_ext, - create(Data.Event_ServerMessageSchema, { message: 'Welcome to TestServer!' }) - )); - - expect(store.getState().server.info.message).toBe('Welcome to TestServer!'); - }); - - it('stores the shutdown payload on Event_ServerShutdown', () => { - connectAndHandshake(); - - deliverMessage(buildSessionEventMessage( - Data.Event_ServerShutdown_ext, - create(Data.Event_ServerShutdownSchema, { - reason: 'Scheduled maintenance', - minutes: 5, - }) - )); - - const shutdown = store.getState().server.serverShutdown; - expect(shutdown).not.toBeNull(); - expect(shutdown?.reason).toBe('Scheduled maintenance'); - expect(shutdown?.minutes).toBe(5); - }); - - it('appends a notification on Event_NotifyUser', () => { - connectAndHandshake(); - - deliverMessage(buildSessionEventMessage( - Data.Event_NotifyUser_ext, - create(Data.Event_NotifyUserSchema, { - type: Data.Event_NotifyUser_NotificationType.PROMOTION, - customTitle: 'You have been promoted', - customContent: 'Now a judge', - }) - )); - - const notifications = store.getState().server.notifications; - expect(notifications).toHaveLength(1); - expect(notifications[0].customTitle).toBe('You have been promoted'); - }); - - describe('connection closed', () => { - it('prefers reasonStr when provided', () => { - connectAndHandshake(); - - deliverMessage(buildSessionEventMessage( - Data.Event_ConnectionClosed_ext, - create(Data.Event_ConnectionClosedSchema, { - reason: Data.Event_ConnectionClosed_CloseReason.OTHER, - reasonStr: 'kicked by admin', - }) - )); - - const status = store.getState().server.status; - expect(status.state).toBe(App.StatusEnum.DISCONNECTED); - expect(status.description).toBe('kicked by admin'); - }); - - it('maps USER_LIMIT_REACHED to a capacity message', () => { - connectAndHandshake(); - - deliverMessage(buildSessionEventMessage( - Data.Event_ConnectionClosed_ext, - create(Data.Event_ConnectionClosedSchema, { - reason: Data.Event_ConnectionClosed_CloseReason.USER_LIMIT_REACHED, - }) - )); - - expect(store.getState().server.status.description).toContain('maximum user capacity'); - }); - - it('maps LOGGEDINELSEWERE to a multi-session message', () => { - connectAndHandshake(); - - deliverMessage(buildSessionEventMessage( - Data.Event_ConnectionClosed_ext, - create(Data.Event_ConnectionClosedSchema, { - reason: Data.Event_ConnectionClosed_CloseReason.LOGGEDINELSEWERE, - }) - )); - - expect(store.getState().server.status.description).toContain('another location'); - }); - }); -}); diff --git a/webclient/integration/src/users.spec.ts b/webclient/integration/src/users.spec.ts deleted file mode 100644 index 03005abba..000000000 --- a/webclient/integration/src/users.spec.ts +++ /dev/null @@ -1,120 +0,0 @@ -// User-list and social scenarios — user presence, buddy/ignore lists, DMs. - -import { create } from '@bufbuild/protobuf'; -import { describe, expect, it } from 'vitest'; - -import { Data } from '@app/types'; -import { store } from '@app/store'; - -import { connectAndLogin } from './helpers/setup'; -import { - buildResponse, - buildResponseMessage, - buildSessionEventMessage, - deliverMessage, -} from './helpers/protobuf-builders'; -import { findLastSessionCommand } from './helpers/command-capture'; - -function makeUser(name: string): Data.ServerInfo_User { - return create(Data.ServerInfo_UserSchema, { - name, - userLevel: Data.ServerInfo_User_UserLevelFlag.IsRegistered, - }); -} - -describe('users', () => { - it('populates state.users from the Response_ListUsers post-login', () => { - connectAndLogin(); - - const listUsers = findLastSessionCommand(Data.Command_ListUsers_ext); - const users = [makeUser('alice'), makeUser('bob'), makeUser('carol')]; - deliverMessage(buildResponseMessage(buildResponse({ - cmdId: listUsers.cmdId, - responseCode: Data.Response_ResponseCode.RespOk, - ext: Data.Response_ListUsers_ext, - value: create(Data.Response_ListUsersSchema, { userList: users }), - }))); - - expect(Object.keys(store.getState().server.users).sort()).toEqual(['alice', 'bob', 'carol']); - }); - - it('appends on Event_UserJoined and removes on Event_UserLeft', () => { - connectAndLogin(); - - deliverMessage(buildSessionEventMessage( - Data.Event_UserJoined_ext, - create(Data.Event_UserJoinedSchema, { userInfo: makeUser('bob') }) - )); - expect('bob' in store.getState().server.users).toBe(true); - - deliverMessage(buildSessionEventMessage( - Data.Event_UserLeft_ext, - create(Data.Event_UserLeftSchema, { name: 'bob' }) - )); - expect('bob' in store.getState().server.users).toBe(false); - }); - - it('adds a user to buddyList on Event_AddToList with listName=buddy', () => { - connectAndLogin(); - - deliverMessage(buildSessionEventMessage( - Data.Event_AddToList_ext, - create(Data.Event_AddToListSchema, { - listName: 'buddy', - userInfo: makeUser('bob'), - }) - )); - - expect('bob' in store.getState().server.buddyList).toBe(true); - expect(store.getState().server.ignoreList).toEqual({}); - }); - - it('adds a user to ignoreList on Event_AddToList with listName=ignore', () => { - connectAndLogin(); - - deliverMessage(buildSessionEventMessage( - Data.Event_AddToList_ext, - create(Data.Event_AddToListSchema, { - listName: 'ignore', - userInfo: makeUser('mallory'), - }) - )); - - expect('mallory' in store.getState().server.ignoreList).toBe(true); - expect(store.getState().server.buddyList).toEqual({}); - }); - - it('files an incoming direct message under the sender', () => { - connectAndLogin('alice'); - - deliverMessage(buildSessionEventMessage( - Data.Event_UserMessage_ext, - create(Data.Event_UserMessageSchema, { - senderName: 'bob', - receiverName: 'alice', - message: 'hi alice', - }) - )); - - const { messages } = store.getState().server; - expect(messages.bob).toHaveLength(1); - expect(messages.bob[0].message).toBe('hi alice'); - }); - - it('files an outgoing direct message under the recipient', () => { - connectAndLogin('alice'); - - deliverMessage(buildSessionEventMessage( - Data.Event_UserMessage_ext, - create(Data.Event_UserMessageSchema, { - senderName: 'alice', - receiverName: 'bob', - message: 'hey bob', - }) - )); - - const { messages } = store.getState().server; - expect(messages.bob).toHaveLength(1); - expect(messages.bob[0].message).toBe('hey bob'); - }); -}); diff --git a/webclient/integration/tsconfig.json b/webclient/integration/tsconfig.json deleted file mode 100644 index 8f35df166..000000000 --- a/webclient/integration/tsconfig.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "extends": "../tsconfig.json", - "compilerOptions": { - "types": ["node"] - }, - "include": ["./**/*.ts"] -} diff --git a/webclient/package-lock.json b/webclient/package-lock.json index ed7f1854a..9f48a49b2 100644 --- a/webclient/package-lock.json +++ b/webclient/package-lock.json @@ -23,6 +23,7 @@ "i18next-browser-languagedetector": "^8.2.1", "i18next-icu": "^2.0.3", "intl-messageformat": "^11.2.1", + "lodash": "^4.17.21", "prop-types": "^15.8.1", "react": "^19.0.0", "react-dom": "^19.0.0", @@ -44,6 +45,7 @@ "@testing-library/jest-dom": "^6.4.0", "@testing-library/react": "^16.3.2", "@types/dompurify": "^3.0.5", + "@types/lodash": "^4.14.179", "@types/node": "^22.19.17", "@types/prop-types": "^15.7.4", "@types/react": "^19.0.0", @@ -55,8 +57,6 @@ "@vitejs/plugin-react": "^6.0.1", "@vitest/coverage-v8": "^4.1.4", "eslint": "^10.2.0", - "eslint-import-resolver-typescript": "^4.4.4", - "eslint-plugin-boundaries": "^6.0.2", "fs-extra": "^11.3.4", "globals": "^17.5.0", "husky": "^9.1.7", @@ -262,23 +262,6 @@ "node": ">=18" } }, - "node_modules/@boundaries/elements": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@boundaries/elements/-/elements-2.0.1.tgz", - "integrity": "sha512-sAWO3D8PFP6pBXdxxW93SQi/KQqqhE2AAHo3AgWfdtJXwO6bfK6/wUN81XnOZk0qRC6vHzUEKhjwVD9dtDWvxg==", - "dev": true, - "license": "MIT", - "dependencies": { - "eslint-import-resolver-node": "0.3.9", - "eslint-module-utils": "2.12.1", - "handlebars": "4.7.9", - "is-core-module": "2.16.1", - "micromatch": "4.0.8" - }, - "engines": { - "node": ">=18.18" - } - }, "node_modules/@bramus/specificity": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", @@ -1770,6 +1753,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/lodash": { + "version": "4.17.24", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz", + "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "22.19.17", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz", @@ -2100,288 +2090,6 @@ "typescript": "*" } }, - "node_modules/@unrs/resolver-binding-android-arm-eabi": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz", - "integrity": "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@unrs/resolver-binding-android-arm64": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz", - "integrity": "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@unrs/resolver-binding-darwin-arm64": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz", - "integrity": "sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@unrs/resolver-binding-darwin-x64": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz", - "integrity": "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@unrs/resolver-binding-freebsd-x64": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz", - "integrity": "sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz", - "integrity": "sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz", - "integrity": "sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz", - "integrity": "sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-arm64-musl": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz", - "integrity": "sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz", - "integrity": "sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz", - "integrity": "sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz", - "integrity": "sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz", - "integrity": "sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-x64-gnu": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz", - "integrity": "sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-x64-musl": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.1.tgz", - "integrity": "sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-wasm32-wasi": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz", - "integrity": "sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@napi-rs/wasm-runtime": "^0.2.11" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { - "version": "0.2.12", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", - "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "^1.4.3", - "@emnapi/runtime": "^1.4.3", - "@tybys/wasm-util": "^0.10.0" - } - }, - "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz", - "integrity": "sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz", - "integrity": "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@unrs/resolver-binding-win32-x64-msvc": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz", - "integrity": "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, "node_modules/@vitejs/plugin-react": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz", @@ -2709,19 +2417,6 @@ "node": "18 || 20 || >=22" } }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -2741,39 +2436,6 @@ "node": ">=18" } }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/chalk/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -2783,26 +2445,6 @@ "node": ">=6" } }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" - }, "node_modules/convert-source-map": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", @@ -3089,137 +2731,6 @@ } } }, - "node_modules/eslint-import-context": { - "version": "0.1.9", - "resolved": "https://registry.npmjs.org/eslint-import-context/-/eslint-import-context-0.1.9.tgz", - "integrity": "sha512-K9Hb+yRaGAGUbwjhFNHvSmmkZs9+zbuoe3kFQ4V1wYjrepUFYM2dZAfNtjbbj3qsPfUfsA68Bx/ICWQMi+C8Eg==", - "dev": true, - "license": "MIT", - "dependencies": { - "get-tsconfig": "^4.10.1", - "stable-hash-x": "^0.2.0" - }, - "engines": { - "node": "^12.20.0 || ^14.18.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint-import-context" - }, - "peerDependencies": { - "unrs-resolver": "^1.0.0" - }, - "peerDependenciesMeta": { - "unrs-resolver": { - "optional": true - } - } - }, - "node_modules/eslint-import-resolver-node": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", - "integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^3.2.7", - "is-core-module": "^2.13.0", - "resolve": "^1.22.4" - } - }, - "node_modules/eslint-import-resolver-node/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.1" - } - }, - "node_modules/eslint-import-resolver-typescript": { - "version": "4.4.4", - "resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-4.4.4.tgz", - "integrity": "sha512-1iM2zeBvrYmUNTj2vSC/90JTHDth+dfOfiNKkxApWRsTJYNrc8rOdxxIf5vazX+BiAXTeOT0UvWpGI/7qIWQOw==", - "dev": true, - "license": "ISC", - "dependencies": { - "debug": "^4.4.1", - "eslint-import-context": "^0.1.8", - "get-tsconfig": "^4.10.1", - "is-bun-module": "^2.0.0", - "stable-hash-x": "^0.2.0", - "tinyglobby": "^0.2.14", - "unrs-resolver": "^1.7.11" - }, - "engines": { - "node": "^16.17.0 || >=18.6.0" - }, - "funding": { - "url": "https://opencollective.com/eslint-import-resolver-typescript" - }, - "peerDependencies": { - "eslint": "*", - "eslint-plugin-import": "*", - "eslint-plugin-import-x": "*" - }, - "peerDependenciesMeta": { - "eslint-plugin-import": { - "optional": true - }, - "eslint-plugin-import-x": { - "optional": true - } - } - }, - "node_modules/eslint-module-utils": { - "version": "2.12.1", - "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz", - "integrity": "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^3.2.7" - }, - "engines": { - "node": ">=4" - }, - "peerDependenciesMeta": { - "eslint": { - "optional": true - } - } - }, - "node_modules/eslint-module-utils/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.1" - } - }, - "node_modules/eslint-plugin-boundaries": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/eslint-plugin-boundaries/-/eslint-plugin-boundaries-6.0.2.tgz", - "integrity": "sha512-wSHgiYeMEbziP91lH0UQ9oslgF2djG1x+LV9z/qO19ggMKZaCB8pKIGePHAY91eLF4EAgpsxQk8MRSFGRPfPzw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@boundaries/elements": "2.0.1", - "chalk": "4.1.2", - "eslint-import-resolver-node": "0.3.9", - "eslint-module-utils": "2.12.1", - "handlebars": "4.7.9", - "micromatch": "4.0.8" - }, - "engines": { - "node": ">=18.18" - }, - "peerDependencies": { - "eslint": ">=6.0.0" - } - }, "node_modules/eslint-scope": { "version": "9.1.2", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", @@ -3424,19 +2935,6 @@ "node": ">=16.0.0" } }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/final-form": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/final-form/-/final-form-5.0.0.tgz", @@ -3545,19 +3043,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/get-tsconfig": { - "version": "4.14.0", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", - "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", - "dev": true, - "license": "MIT", - "dependencies": { - "resolve-pkg-maps": "^1.0.0" - }, - "funding": { - "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" - } - }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -3591,38 +3076,6 @@ "dev": true, "license": "ISC" }, - "node_modules/handlebars": { - "version": "4.7.9", - "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", - "integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "minimist": "^1.2.5", - "neo-async": "^2.6.2", - "source-map": "^0.6.1", - "wordwrap": "^1.0.0" - }, - "bin": { - "handlebars": "bin/handlebars" - }, - "engines": { - "node": ">=0.4.7" - }, - "optionalDependencies": { - "uglify-js": "^3.1.4" - } - }, - "node_modules/handlebars/node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -3826,16 +3279,6 @@ "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", "license": "MIT" }, - "node_modules/is-bun-module": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-bun-module/-/is-bun-module-2.0.0.tgz", - "integrity": "sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "semver": "^7.7.1" - } - }, "node_modules/is-core-module": { "version": "2.16.1", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", @@ -3874,16 +3317,6 @@ "node": ">=0.10.0" } }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, "node_modules/is-potential-custom-element-name": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", @@ -4343,6 +3776,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -4420,33 +3859,6 @@ "dev": true, "license": "CC0-1.0" }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, - "license": "MIT", - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, - "node_modules/micromatch/node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/min-indent": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", @@ -4473,16 +3885,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -4508,22 +3910,6 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/napi-postinstall": { - "version": "0.3.4", - "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", - "integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==", - "dev": true, - "license": "MIT", - "bin": { - "napi-postinstall": "lib/cli.js" - }, - "engines": { - "node": "^12.20.0 || ^14.18.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/napi-postinstall" - } - }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -4531,13 +3917,6 @@ "dev": true, "license": "MIT" }, - "node_modules/neo-async": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", - "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", - "dev": true, - "license": "MIT" - }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -5057,16 +4436,6 @@ "node": ">=4" } }, - "node_modules/resolve-pkg-maps": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", - "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" - } - }, "node_modules/rolldown": { "version": "1.0.0-rc.15", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.15.tgz", @@ -5204,16 +4573,6 @@ "node": ">=0.10.0" } }, - "node_modules/stable-hash-x": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/stable-hash-x/-/stable-hash-x-0.2.0.tgz", - "integrity": "sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - } - }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -5343,19 +4702,6 @@ "dev": true, "license": "MIT" }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, "node_modules/tough-cookie": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", @@ -5452,20 +4798,6 @@ "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/uglify-js": { - "version": "3.19.3", - "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", - "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", - "dev": true, - "license": "BSD-2-Clause", - "optional": true, - "bin": { - "uglifyjs": "bin/uglifyjs" - }, - "engines": { - "node": ">=0.8.0" - } - }, "node_modules/undici": { "version": "7.25.0", "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", @@ -5493,41 +4825,6 @@ "node": ">= 10.0.0" } }, - "node_modules/unrs-resolver": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz", - "integrity": "sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "napi-postinstall": "^0.3.0" - }, - "funding": { - "url": "https://opencollective.com/unrs-resolver" - }, - "optionalDependencies": { - "@unrs/resolver-binding-android-arm-eabi": "1.11.1", - "@unrs/resolver-binding-android-arm64": "1.11.1", - "@unrs/resolver-binding-darwin-arm64": "1.11.1", - "@unrs/resolver-binding-darwin-x64": "1.11.1", - "@unrs/resolver-binding-freebsd-x64": "1.11.1", - "@unrs/resolver-binding-linux-arm-gnueabihf": "1.11.1", - "@unrs/resolver-binding-linux-arm-musleabihf": "1.11.1", - "@unrs/resolver-binding-linux-arm64-gnu": "1.11.1", - "@unrs/resolver-binding-linux-arm64-musl": "1.11.1", - "@unrs/resolver-binding-linux-ppc64-gnu": "1.11.1", - "@unrs/resolver-binding-linux-riscv64-gnu": "1.11.1", - "@unrs/resolver-binding-linux-riscv64-musl": "1.11.1", - "@unrs/resolver-binding-linux-s390x-gnu": "1.11.1", - "@unrs/resolver-binding-linux-x64-gnu": "1.11.1", - "@unrs/resolver-binding-linux-x64-musl": "1.11.1", - "@unrs/resolver-binding-wasm32-wasi": "1.11.1", - "@unrs/resolver-binding-win32-arm64-msvc": "1.11.1", - "@unrs/resolver-binding-win32-ia32-msvc": "1.11.1", - "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" - } - }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -5815,13 +5112,6 @@ "node": ">=0.10.0" } }, - "node_modules/wordwrap": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", - "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", - "dev": true, - "license": "MIT" - }, "node_modules/xml-name-validator": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", diff --git a/webclient/package.json b/webclient/package.json index 774cef831..b2927025f 100644 --- a/webclient/package.json +++ b/webclient/package.json @@ -9,14 +9,10 @@ "start": "vite", "preview": "vite preview", "test": "vitest run", - "test:coverage": "npm run test -- --coverage", "test:watch": "vitest", - "test:integration": "vitest run --config vitest.integration.config.ts", - "test:integration:coverage": "npm run test:integration -- --coverage", - "lint": "eslint src", - "lint:fix": "eslint src --fix", - "golden": "npm run lint && npm run test && npm run test:integration", - "golden:coverage": "npm run lint && npm run test:coverage && npm run test:integration:coverage", + "lint": "eslint src/", + "lint:fix": "eslint src/ --fix", + "golden": "npm run lint && npm run test", "prepare": "cd .. && husky", "translate": "node prebuild.js -i18nOnly", "proto:generate": "npx buf generate" @@ -37,6 +33,7 @@ "i18next-browser-languagedetector": "^8.2.1", "i18next-icu": "^2.0.3", "intl-messageformat": "^11.2.1", + "lodash": "^4.17.21", "prop-types": "^15.8.1", "react": "^19.0.0", "react-dom": "^19.0.0", @@ -58,6 +55,7 @@ "@testing-library/jest-dom": "^6.4.0", "@testing-library/react": "^16.3.2", "@types/dompurify": "^3.0.5", + "@types/lodash": "^4.14.179", "@types/node": "^22.19.17", "@types/prop-types": "^15.7.4", "@types/react": "^19.0.0", @@ -69,8 +67,6 @@ "@vitejs/plugin-react": "^6.0.1", "@vitest/coverage-v8": "^4.1.4", "eslint": "^10.2.0", - "eslint-import-resolver-typescript": "^4.4.4", - "eslint-plugin-boundaries": "^6.0.2", "fs-extra": "^11.3.4", "globals": "^17.5.0", "husky": "^9.1.7", diff --git a/webclient/src/api/AdminService.spec.ts b/webclient/src/api/AdminService.spec.ts new file mode 100644 index 000000000..1964a8368 --- /dev/null +++ b/webclient/src/api/AdminService.spec.ts @@ -0,0 +1,46 @@ +vi.mock('@app/websocket', () => ({ + AdminCommands: { + adjustMod: vi.fn(), + reloadConfig: vi.fn(), + shutdownServer: vi.fn(), + updateServerMessage: vi.fn(), + }, +})); + +import { AdminService } from './AdminService'; +import { AdminCommands } from '@app/websocket'; + +describe('AdminService', () => { + describe('adjustMod', () => { + it('delegates to AdminCommands.adjustMod with all arguments', () => { + AdminService.adjustMod('alice', true, false); + expect(AdminCommands.adjustMod).toHaveBeenCalledWith('alice', true, false); + }); + + it('delegates with optional arguments omitted', () => { + AdminService.adjustMod('alice'); + expect(AdminCommands.adjustMod).toHaveBeenCalledWith('alice', undefined, undefined); + }); + }); + + describe('reloadConfig', () => { + it('delegates to AdminCommands.reloadConfig', () => { + AdminService.reloadConfig(); + expect(AdminCommands.reloadConfig).toHaveBeenCalled(); + }); + }); + + describe('shutdownServer', () => { + it('delegates to AdminCommands.shutdownServer', () => { + AdminService.shutdownServer('maintenance', 10); + expect(AdminCommands.shutdownServer).toHaveBeenCalledWith('maintenance', 10); + }); + }); + + describe('updateServerMessage', () => { + it('delegates to AdminCommands.updateServerMessage', () => { + AdminService.updateServerMessage(); + expect(AdminCommands.updateServerMessage).toHaveBeenCalled(); + }); + }); +}); diff --git a/webclient/src/api/AdminService.tsx b/webclient/src/api/AdminService.tsx new file mode 100644 index 000000000..c280fca7b --- /dev/null +++ b/webclient/src/api/AdminService.tsx @@ -0,0 +1,19 @@ +import { AdminCommands } from '@app/websocket'; + +export class AdminService { + static adjustMod(userName: string, shouldBeMod?: boolean, shouldBeJudge?: boolean): void { + AdminCommands.adjustMod(userName, shouldBeMod, shouldBeJudge); + } + + static reloadConfig(): void { + AdminCommands.reloadConfig(); + } + + static shutdownServer(reason: string, minutes: number): void { + AdminCommands.shutdownServer(reason, minutes); + } + + static updateServerMessage(): void { + AdminCommands.updateServerMessage(); + } +} diff --git a/webclient/src/api/AuthenticationService.spec.ts b/webclient/src/api/AuthenticationService.spec.ts new file mode 100644 index 000000000..0b2dfbc1c --- /dev/null +++ b/webclient/src/api/AuthenticationService.spec.ts @@ -0,0 +1,166 @@ +vi.mock('@app/websocket', () => ({ + SessionCommands: { + connect: vi.fn(), + disconnect: vi.fn(), + }, +})); + +vi.mock('../generated/proto/serverinfo_user_pb', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + ServerInfo_User_UserLevelFlag: { + IsModerator: 4, + }, + }; +}); + +import { AuthenticationService } from './AuthenticationService'; +import { SessionCommands } from '@app/websocket'; +import { App, Data } from '@app/types'; +import { create } from '@bufbuild/protobuf'; + +const baseTransport = { host: 'localhost', port: '4748' }; + +describe('AuthenticationService', () => { + describe('login', () => { + it('calls SessionCommands.connect with LOGIN reason', () => { + AuthenticationService.login({ ...baseTransport, userName: 'user', password: 'pw' }); + expect(SessionCommands.connect).toHaveBeenCalledWith( + expect.objectContaining({ + ...baseTransport, + userName: 'user', + password: 'pw', + reason: App.WebSocketConnectReason.LOGIN, + }) + ); + }); + }); + + describe('testConnection', () => { + it('calls SessionCommands.connect with TEST_CONNECTION reason', () => { + AuthenticationService.testConnection(baseTransport); + expect(SessionCommands.connect).toHaveBeenCalledWith( + expect.objectContaining({ ...baseTransport, reason: App.WebSocketConnectReason.TEST_CONNECTION }) + ); + }); + }); + + describe('register', () => { + it('calls SessionCommands.connect with REGISTER reason', () => { + AuthenticationService.register({ + ...baseTransport, + userName: 'user', + password: 'pw', + email: 'a@b.com', + country: 'US', + realName: 'User', + }); + expect(SessionCommands.connect).toHaveBeenCalledWith( + expect.objectContaining({ userName: 'user', reason: App.WebSocketConnectReason.REGISTER }) + ); + }); + }); + + describe('activateAccount', () => { + it('calls SessionCommands.connect with ACTIVATE_ACCOUNT reason', () => { + AuthenticationService.activateAccount({ + ...baseTransport, + userName: 'user', + token: 'tok', + }); + expect(SessionCommands.connect).toHaveBeenCalledWith( + expect.objectContaining({ token: 'tok', reason: App.WebSocketConnectReason.ACTIVATE_ACCOUNT }) + ); + }); + }); + + describe('resetPasswordRequest', () => { + it('calls SessionCommands.connect with PASSWORD_RESET_REQUEST reason', () => { + AuthenticationService.resetPasswordRequest({ ...baseTransport, userName: 'user' }); + expect(SessionCommands.connect).toHaveBeenCalledWith( + expect.objectContaining({ userName: 'user', reason: App.WebSocketConnectReason.PASSWORD_RESET_REQUEST }) + ); + }); + }); + + describe('resetPasswordChallenge', () => { + it('calls SessionCommands.connect with PASSWORD_RESET_CHALLENGE reason', () => { + AuthenticationService.resetPasswordChallenge({ + ...baseTransport, + userName: 'user', + email: 'a@b.com', + }); + expect(SessionCommands.connect).toHaveBeenCalledWith( + expect.objectContaining({ email: 'a@b.com', reason: App.WebSocketConnectReason.PASSWORD_RESET_CHALLENGE }) + ); + }); + }); + + describe('resetPassword', () => { + it('calls SessionCommands.connect with PASSWORD_RESET reason', () => { + AuthenticationService.resetPassword({ + ...baseTransport, + userName: 'user', + token: 'tok', + newPassword: 'newpw', + }); + expect(SessionCommands.connect).toHaveBeenCalledWith( + expect.objectContaining({ newPassword: 'newpw', reason: App.WebSocketConnectReason.PASSWORD_RESET }) + ); + }); + }); + + describe('disconnect', () => { + it('delegates to SessionCommands.disconnect', () => { + AuthenticationService.disconnect(); + expect(SessionCommands.disconnect).toHaveBeenCalled(); + }); + }); + + describe('isConnected', () => { + it('returns true when state is LOGGED_IN', () => { + expect(AuthenticationService.isConnected(App.StatusEnum.LOGGED_IN)).toBe(true); + }); + + it('returns false when state is DISCONNECTED', () => { + expect(AuthenticationService.isConnected(App.StatusEnum.DISCONNECTED)).toBe(false); + }); + + it('returns false when state is CONNECTING', () => { + expect(AuthenticationService.isConnected(App.StatusEnum.CONNECTING)).toBe(false); + }); + + it('returns false when state is CONNECTED', () => { + expect(AuthenticationService.isConnected(App.StatusEnum.CONNECTED)).toBe(false); + }); + + it('returns false when state is LOGGING_IN', () => { + expect(AuthenticationService.isConnected(App.StatusEnum.LOGGING_IN)).toBe(false); + }); + }); + + describe('isModerator', () => { + it('returns true when userLevel has the IsModerator bit set', () => { + expect(AuthenticationService.isModerator(create(Data.ServerInfo_UserSchema, { userLevel: 4 }))).toBe(true); + }); + + it('returns true when userLevel has IsModerator and other bits set', () => { + expect(AuthenticationService.isModerator(create(Data.ServerInfo_UserSchema, { userLevel: 7 }))).toBe(true); + }); + + it('returns false when userLevel does not have the IsModerator bit', () => { + expect(AuthenticationService.isModerator(create(Data.ServerInfo_UserSchema, { userLevel: 1 }))).toBe(false); + }); + + it('returns false for admin-only userLevel without moderator bit', () => { + expect(AuthenticationService.isModerator(create(Data.ServerInfo_UserSchema, { userLevel: 8 }))).toBe(false); + }); + }); + + describe('isAdmin', () => { + it('returns undefined (not yet implemented)', () => { + expect(AuthenticationService.isAdmin()).toBeUndefined(); + }); + }); +}); diff --git a/webclient/src/api/AuthenticationService.tsx b/webclient/src/api/AuthenticationService.tsx new file mode 100644 index 000000000..bacc8e350 --- /dev/null +++ b/webclient/src/api/AuthenticationService.tsx @@ -0,0 +1,50 @@ +import { App, Data, Enriched } from '@app/types'; +import { SessionCommands } from '@app/websocket'; + +export class AuthenticationService { + static login(options: Omit): void { + SessionCommands.connect({ ...options, reason: App.WebSocketConnectReason.LOGIN }); + } + + static testConnection(options: Omit): void { + SessionCommands.connect({ ...options, reason: App.WebSocketConnectReason.TEST_CONNECTION }); + } + + static register(options: Omit): void { + SessionCommands.connect({ ...options, reason: App.WebSocketConnectReason.REGISTER }); + } + + static activateAccount(options: Omit): void { + SessionCommands.connect({ ...options, reason: App.WebSocketConnectReason.ACTIVATE_ACCOUNT }); + } + + static resetPasswordRequest(options: Omit): void { + SessionCommands.connect({ ...options, reason: App.WebSocketConnectReason.PASSWORD_RESET_REQUEST }); + } + + static resetPasswordChallenge(options: Omit): void { + SessionCommands.connect({ ...options, reason: App.WebSocketConnectReason.PASSWORD_RESET_CHALLENGE }); + } + + static resetPassword(options: Omit): void { + SessionCommands.connect({ ...options, reason: App.WebSocketConnectReason.PASSWORD_RESET }); + } + + static disconnect(): void { + SessionCommands.disconnect(); + } + + static isConnected(state: number): boolean { + return state === App.StatusEnum.LOGGED_IN; + } + + static isModerator(user: Data.ServerInfo_User): boolean { + const moderatorLevel = Data.ServerInfo_User_UserLevelFlag.IsModerator; + // @TODO tell cockatrice not to do this so shittily + return (user.userLevel & moderatorLevel) === moderatorLevel; + } + + static isAdmin() { + + } +} diff --git a/webclient/src/api/ModeratorService.spec.ts b/webclient/src/api/ModeratorService.spec.ts new file mode 100644 index 000000000..f32a58d09 --- /dev/null +++ b/webclient/src/api/ModeratorService.spec.ts @@ -0,0 +1,73 @@ +vi.mock('@app/websocket', () => ({ + ModeratorCommands: { + banFromServer: vi.fn(), + getBanHistory: vi.fn(), + getWarnHistory: vi.fn(), + getWarnList: vi.fn(), + viewLogHistory: vi.fn(), + warnUser: vi.fn(), + }, +})); + +import { ModeratorService } from './ModeratorService'; +import { ModeratorCommands } from '@app/websocket'; +import { Data } from '@app/types'; + +describe('ModeratorService', () => { + describe('banFromServer', () => { + it('delegates to ModeratorCommands.banFromServer with all arguments', () => { + ModeratorService.banFromServer(30, 'alice', '1.2.3.4', 'reason', 'visible reason', 'cid', 1); + expect(ModeratorCommands.banFromServer).toHaveBeenCalledWith( + 30, 'alice', '1.2.3.4', 'reason', 'visible reason', 'cid', 1 + ); + }); + + it('delegates with only required argument', () => { + ModeratorService.banFromServer(60); + expect(ModeratorCommands.banFromServer).toHaveBeenCalledWith( + 60, undefined, undefined, undefined, undefined, undefined, undefined + ); + }); + }); + + describe('getBanHistory', () => { + it('delegates to ModeratorCommands.getBanHistory', () => { + ModeratorService.getBanHistory('alice'); + expect(ModeratorCommands.getBanHistory).toHaveBeenCalledWith('alice'); + }); + }); + + describe('getWarnHistory', () => { + it('delegates to ModeratorCommands.getWarnHistory', () => { + ModeratorService.getWarnHistory('alice'); + expect(ModeratorCommands.getWarnHistory).toHaveBeenCalledWith('alice'); + }); + }); + + describe('getWarnList', () => { + it('delegates to ModeratorCommands.getWarnList', () => { + ModeratorService.getWarnList('mod1', 'alice', 'cid123'); + expect(ModeratorCommands.getWarnList).toHaveBeenCalledWith('mod1', 'alice', 'cid123'); + }); + }); + + describe('viewLogHistory', () => { + it('delegates to ModeratorCommands.viewLogHistory', () => { + const filters: Data.ViewLogHistoryParams = { dateRange: 7, userName: 'alice' }; + ModeratorService.viewLogHistory(filters); + expect(ModeratorCommands.viewLogHistory).toHaveBeenCalledWith(filters); + }); + }); + + describe('warnUser', () => { + it('delegates to ModeratorCommands.warnUser with all arguments', () => { + ModeratorService.warnUser('alice', 'spamming', 'cid', 5); + expect(ModeratorCommands.warnUser).toHaveBeenCalledWith('alice', 'spamming', 'cid', 5); + }); + + it('delegates with only required arguments', () => { + ModeratorService.warnUser('alice', 'spamming'); + expect(ModeratorCommands.warnUser).toHaveBeenCalledWith('alice', 'spamming', undefined, undefined); + }); + }); +}); diff --git a/webclient/src/api/ModeratorService.tsx b/webclient/src/api/ModeratorService.tsx new file mode 100644 index 000000000..2fa9e9019 --- /dev/null +++ b/webclient/src/api/ModeratorService.tsx @@ -0,0 +1,29 @@ +import { ModeratorCommands } from '@app/websocket'; +import { Data } from '@app/types'; + +export class ModeratorService { + static banFromServer(minutes: number, userName?: string, address?: string, reason?: string, + visibleReason?: string, clientid?: string, removeMessages?: number): void { + ModeratorCommands.banFromServer(minutes, userName, address, reason, visibleReason, clientid, removeMessages); + } + + static getBanHistory(userName: string): void { + ModeratorCommands.getBanHistory(userName); + } + + static getWarnHistory(userName: string): void { + ModeratorCommands.getWarnHistory(userName); + } + + static getWarnList(modName: string, userName: string, userClientid: string): void { + ModeratorCommands.getWarnList(modName, userName, userClientid); + } + + static viewLogHistory(filters: Data.ViewLogHistoryParams): void { + ModeratorCommands.viewLogHistory(filters); + } + + static warnUser(userName: string, reason: string, clientid?: string, removeMessages?: number): void { + ModeratorCommands.warnUser(userName, reason, clientid, removeMessages); + } +} diff --git a/webclient/src/api/RoomsService.spec.ts b/webclient/src/api/RoomsService.spec.ts new file mode 100644 index 000000000..80ff8d4cd --- /dev/null +++ b/webclient/src/api/RoomsService.spec.ts @@ -0,0 +1,35 @@ +vi.mock('@app/websocket', () => ({ + SessionCommands: { + joinRoom: vi.fn(), + }, + RoomCommands: { + leaveRoom: vi.fn(), + roomSay: vi.fn(), + }, +})); + +import { RoomsService } from './RoomsService'; +import { RoomCommands, SessionCommands } from '@app/websocket'; + +describe('RoomsService', () => { + describe('joinRoom', () => { + it('delegates to SessionCommands.joinRoom', () => { + RoomsService.joinRoom(42); + expect(SessionCommands.joinRoom).toHaveBeenCalledWith(42); + }); + }); + + describe('leaveRoom', () => { + it('delegates to RoomCommands.leaveRoom', () => { + RoomsService.leaveRoom(42); + expect(RoomCommands.leaveRoom).toHaveBeenCalledWith(42); + }); + }); + + describe('roomSay', () => { + it('delegates to RoomCommands.roomSay', () => { + RoomsService.roomSay(42, 'hello room'); + expect(RoomCommands.roomSay).toHaveBeenCalledWith(42, 'hello room'); + }); + }); +}); diff --git a/webclient/src/api/RoomsService.tsx b/webclient/src/api/RoomsService.tsx new file mode 100644 index 000000000..b2f9ddc4e --- /dev/null +++ b/webclient/src/api/RoomsService.tsx @@ -0,0 +1,15 @@ +import { RoomCommands, SessionCommands } from '@app/websocket'; + +export class RoomsService { + static joinRoom(roomId: number): void { + SessionCommands.joinRoom(roomId); + } + + static leaveRoom(roomId: number): void { + RoomCommands.leaveRoom(roomId); + } + + static roomSay(roomId: number, message: string): void { + RoomCommands.roomSay(roomId, message); + } +} diff --git a/webclient/src/api/SessionService.spec.ts b/webclient/src/api/SessionService.spec.ts new file mode 100644 index 000000000..879d4c3ef --- /dev/null +++ b/webclient/src/api/SessionService.spec.ts @@ -0,0 +1,100 @@ +vi.mock('@app/websocket', () => ({ + SessionCommands: { + addToBuddyList: vi.fn(), + removeFromBuddyList: vi.fn(), + addToIgnoreList: vi.fn(), + removeFromIgnoreList: vi.fn(), + accountPassword: vi.fn(), + accountEdit: vi.fn(), + accountImage: vi.fn(), + message: vi.fn(), + getUserInfo: vi.fn(), + getGamesOfUser: vi.fn(), + }, +})); + +import { SessionService } from './SessionService'; +import { SessionCommands } from '@app/websocket'; + +describe('SessionService', () => { + describe('addToBuddyList', () => { + it('delegates to SessionCommands.addToBuddyList', () => { + SessionService.addToBuddyList('alice'); + expect(SessionCommands.addToBuddyList).toHaveBeenCalledWith('alice'); + }); + }); + + describe('removeFromBuddyList', () => { + it('delegates to SessionCommands.removeFromBuddyList', () => { + SessionService.removeFromBuddyList('alice'); + expect(SessionCommands.removeFromBuddyList).toHaveBeenCalledWith('alice'); + }); + }); + + describe('addToIgnoreList', () => { + it('delegates to SessionCommands.addToIgnoreList', () => { + SessionService.addToIgnoreList('bob'); + expect(SessionCommands.addToIgnoreList).toHaveBeenCalledWith('bob'); + }); + }); + + describe('removeFromIgnoreList', () => { + it('delegates to SessionCommands.removeFromIgnoreList', () => { + SessionService.removeFromIgnoreList('bob'); + expect(SessionCommands.removeFromIgnoreList).toHaveBeenCalledWith('bob'); + }); + }); + + describe('changeAccountPassword', () => { + it('delegates to SessionCommands.accountPassword with all arguments', () => { + SessionService.changeAccountPassword('oldPw', 'newPw', 'hashedPw'); + expect(SessionCommands.accountPassword).toHaveBeenCalledWith('oldPw', 'newPw', 'hashedPw'); + }); + + it('delegates without hashedNewPassword when omitted', () => { + SessionService.changeAccountPassword('oldPw', 'newPw'); + expect(SessionCommands.accountPassword).toHaveBeenCalledWith('oldPw', 'newPw', undefined); + }); + }); + + describe('changeAccountDetails', () => { + it('delegates to SessionCommands.accountEdit with all arguments', () => { + SessionService.changeAccountDetails('pw', 'Alice', 'alice@example.com', 'US'); + expect(SessionCommands.accountEdit).toHaveBeenCalledWith('pw', 'Alice', 'alice@example.com', 'US'); + }); + + it('delegates with only required argument', () => { + SessionService.changeAccountDetails('pw'); + expect(SessionCommands.accountEdit).toHaveBeenCalledWith('pw', undefined, undefined, undefined); + }); + }); + + describe('changeAccountImage', () => { + it('delegates to SessionCommands.accountImage', () => { + const image = new Uint8Array([1, 2, 3]); + SessionService.changeAccountImage(image); + expect(SessionCommands.accountImage).toHaveBeenCalledWith(image); + }); + }); + + describe('sendDirectMessage', () => { + it('delegates to SessionCommands.message', () => { + SessionService.sendDirectMessage('alice', 'hello'); + expect(SessionCommands.message).toHaveBeenCalledWith('alice', 'hello'); + }); + }); + + describe('getUserInfo', () => { + it('delegates to SessionCommands.getUserInfo', () => { + SessionService.getUserInfo('alice'); + expect(SessionCommands.getUserInfo).toHaveBeenCalledWith('alice'); + }); + }); + + describe('getUserGames', () => { + it('delegates to SessionCommands.getGamesOfUser', () => { + SessionService.getUserGames('alice'); + expect(SessionCommands.getGamesOfUser).toHaveBeenCalledWith('alice'); + }); + }); +}); diff --git a/webclient/src/api/SessionService.tsx b/webclient/src/api/SessionService.tsx new file mode 100644 index 000000000..129cdfc58 --- /dev/null +++ b/webclient/src/api/SessionService.tsx @@ -0,0 +1,43 @@ +import { SessionCommands } from '@app/websocket'; + +export class SessionService { + static addToBuddyList(userName: string) { + SessionCommands.addToBuddyList(userName); + } + + static removeFromBuddyList(userName: string) { + SessionCommands.removeFromBuddyList(userName); + } + + static addToIgnoreList(userName: string) { + SessionCommands.addToIgnoreList(userName); + } + + static removeFromIgnoreList(userName: string) { + SessionCommands.removeFromIgnoreList(userName); + } + + static changeAccountPassword(oldPassword: string, newPassword: string, hashedNewPassword?: string): void { + SessionCommands.accountPassword(oldPassword, newPassword, hashedNewPassword); + } + + static changeAccountDetails(passwordCheck: string, realName?: string, email?: string, country?: string): void { + SessionCommands.accountEdit(passwordCheck, realName, email, country); + } + + static changeAccountImage(image: Uint8Array): void { + SessionCommands.accountImage(image); + } + + static sendDirectMessage(userName: string, message: string): void { + SessionCommands.message(userName, message); + } + + static getUserInfo(userName: string): void { + SessionCommands.getUserInfo(userName); + } + + static getUserGames(userName: string): void { + SessionCommands.getGamesOfUser(userName); + } +} diff --git a/webclient/src/api/index.ts b/webclient/src/api/index.ts index 0acaf2d93..c4f67092e 100644 --- a/webclient/src/api/index.ts +++ b/webclient/src/api/index.ts @@ -1,35 +1,5 @@ -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; - }, -}; +export { AdminService } from './AdminService'; +export { AuthenticationService } from './AuthenticationService'; +export { ModeratorService } from './ModeratorService'; +export { RoomsService } from './RoomsService'; +export { SessionService } from './SessionService'; diff --git a/webclient/src/api/request/AdminRequestImpl.ts b/webclient/src/api/request/AdminRequestImpl.ts deleted file mode 100644 index 5cc21695b..000000000 --- a/webclient/src/api/request/AdminRequestImpl.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { IAdminRequest } from '@app/websocket'; -import { AdminCommands } from '@app/websocket'; - -export class AdminRequestImpl implements IAdminRequest { - adjustMod(userName: string, shouldBeMod?: boolean, shouldBeJudge?: boolean): void { - AdminCommands.adjustMod(userName, shouldBeMod, shouldBeJudge); - } - - reloadConfig(): void { - AdminCommands.reloadConfig(); - } - - shutdownServer(reason: string, minutes: number): void { - AdminCommands.shutdownServer(reason, minutes); - } - - updateServerMessage(): void { - AdminCommands.updateServerMessage(); - } -} diff --git a/webclient/src/api/request/AuthenticationRequestImpl.ts b/webclient/src/api/request/AuthenticationRequestImpl.ts deleted file mode 100644 index 9157d736f..000000000 --- a/webclient/src/api/request/AuthenticationRequestImpl.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { App, Enriched } from '@app/types'; -import type { IAuthenticationRequest } from '@app/websocket'; -import { SessionCommands } from '@app/websocket'; - -export class AuthenticationRequestImpl implements IAuthenticationRequest { - login(options: Omit): void { - SessionCommands.connect({ ...options, reason: App.WebSocketConnectReason.LOGIN }); - } - - testConnection(options: Omit): void { - SessionCommands.connect({ ...options, reason: App.WebSocketConnectReason.TEST_CONNECTION }); - } - - register(options: Omit): void { - SessionCommands.connect({ ...options, reason: App.WebSocketConnectReason.REGISTER }); - } - - activateAccount(options: Omit): void { - SessionCommands.connect({ ...options, reason: App.WebSocketConnectReason.ACTIVATE_ACCOUNT }); - } - - resetPasswordRequest(options: Omit): void { - SessionCommands.connect({ ...options, reason: App.WebSocketConnectReason.PASSWORD_RESET_REQUEST }); - } - - resetPasswordChallenge(options: Omit): void { - SessionCommands.connect({ ...options, reason: App.WebSocketConnectReason.PASSWORD_RESET_CHALLENGE }); - } - - resetPassword(options: Omit): void { - SessionCommands.connect({ ...options, reason: App.WebSocketConnectReason.PASSWORD_RESET }); - } - - disconnect(): void { - SessionCommands.disconnect(); - } -} diff --git a/webclient/src/api/request/GameRequestImpl.ts b/webclient/src/api/request/GameRequestImpl.ts deleted file mode 100644 index e594118cc..000000000 --- a/webclient/src/api/request/GameRequestImpl.ts +++ /dev/null @@ -1,142 +0,0 @@ -import type { IGameRequest } from '@app/websocket'; -import { GameCommands } from '@app/websocket'; - -import { Data } from '@app/types'; - -export class GameRequestImpl implements IGameRequest { - leaveGame(gameId: number): void { - GameCommands.leaveGame(gameId); - } - - kickFromGame(gameId: number, params: Data.KickFromGameParams): void { - GameCommands.kickFromGame(gameId, params); - } - - gameSay(gameId: number, params: Data.GameSayParams): void { - GameCommands.gameSay(gameId, params); - } - - readyStart(gameId: number, params: Data.ReadyStartParams): void { - GameCommands.readyStart(gameId, params); - } - - concede(gameId: number): void { - GameCommands.concede(gameId); - } - - unconcede(gameId: number): void { - GameCommands.unconcede(gameId); - } - - judge(gameId: number, targetId: number, innerGameCommand: Data.GameCommand): void { - GameCommands.judge(gameId, targetId, innerGameCommand); - } - - nextTurn(gameId: number): void { - GameCommands.nextTurn(gameId); - } - - setActivePhase(gameId: number, params: Data.SetActivePhaseParams): void { - GameCommands.setActivePhase(gameId, params); - } - - reverseTurn(gameId: number): void { - GameCommands.reverseTurn(gameId); - } - - moveCard(gameId: number, params: Data.MoveCardParams): void { - GameCommands.moveCard(gameId, params); - } - - flipCard(gameId: number, params: Data.FlipCardParams): void { - GameCommands.flipCard(gameId, params); - } - - attachCard(gameId: number, params: Data.AttachCardParams): void { - GameCommands.attachCard(gameId, params); - } - - createToken(gameId: number, params: Data.CreateTokenParams): void { - GameCommands.createToken(gameId, params); - } - - setCardAttr(gameId: number, params: Data.SetCardAttrParams): void { - GameCommands.setCardAttr(gameId, params); - } - - setCardCounter(gameId: number, params: Data.SetCardCounterParams): void { - GameCommands.setCardCounter(gameId, params); - } - - incCardCounter(gameId: number, params: Data.IncCardCounterParams): void { - GameCommands.incCardCounter(gameId, params); - } - - drawCards(gameId: number, params: Data.DrawCardsParams): void { - GameCommands.drawCards(gameId, params); - } - - undoDraw(gameId: number): void { - GameCommands.undoDraw(gameId); - } - - createArrow(gameId: number, params: Data.CreateArrowParams): void { - GameCommands.createArrow(gameId, params); - } - - deleteArrow(gameId: number, params: Data.DeleteArrowParams): void { - GameCommands.deleteArrow(gameId, params); - } - - createCounter(gameId: number, params: Data.CreateCounterParams): void { - GameCommands.createCounter(gameId, params); - } - - setCounter(gameId: number, params: Data.SetCounterParams): void { - GameCommands.setCounter(gameId, params); - } - - incCounter(gameId: number, params: Data.IncCounterParams): void { - GameCommands.incCounter(gameId, params); - } - - delCounter(gameId: number, params: Data.DelCounterParams): void { - GameCommands.delCounter(gameId, params); - } - - shuffle(gameId: number, params: Data.ShuffleParams): void { - GameCommands.shuffle(gameId, params); - } - - dumpZone(gameId: number, params: Data.DumpZoneParams): void { - GameCommands.dumpZone(gameId, params); - } - - revealCards(gameId: number, params: Data.RevealCardsParams): void { - GameCommands.revealCards(gameId, params); - } - - changeZoneProperties(gameId: number, params: Data.ChangeZonePropertiesParams): void { - GameCommands.changeZoneProperties(gameId, params); - } - - deckSelect(gameId: number, params: Data.DeckSelectParams): void { - GameCommands.deckSelect(gameId, params); - } - - setSideboardPlan(gameId: number, params: Data.SetSideboardPlanParams): void { - GameCommands.setSideboardPlan(gameId, params); - } - - setSideboardLock(gameId: number, params: Data.SetSideboardLockParams): void { - GameCommands.setSideboardLock(gameId, params); - } - - mulligan(gameId: number, params: Data.MulliganParams): void { - GameCommands.mulligan(gameId, params); - } - - rollDie(gameId: number, params: Data.RollDieParams): void { - GameCommands.rollDie(gameId, params); - } -} diff --git a/webclient/src/api/request/ModeratorRequestImpl.ts b/webclient/src/api/request/ModeratorRequestImpl.ts deleted file mode 100644 index 9f5c063da..000000000 --- a/webclient/src/api/request/ModeratorRequestImpl.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { Data } from '@app/types'; -import type { IModeratorRequest } from '@app/websocket'; -import { ModeratorCommands } from '@app/websocket'; - -export class ModeratorRequestImpl implements IModeratorRequest { - banFromServer( - minutes: number, - userName?: string, - address?: string, - reason?: string, - visibleReason?: string, - clientid?: string, - removeMessages?: number - ): void { - ModeratorCommands.banFromServer(minutes, userName, address, reason, visibleReason, clientid, removeMessages); - } - - getBanHistory(userName: string): void { - ModeratorCommands.getBanHistory(userName); - } - - getWarnHistory(userName: string): void { - ModeratorCommands.getWarnHistory(userName); - } - - getWarnList(modName: string, userName: string, userClientid: string): void { - ModeratorCommands.getWarnList(modName, userName, userClientid); - } - - viewLogHistory(filters: Data.ViewLogHistoryParams): void { - ModeratorCommands.viewLogHistory(filters); - } - - warnUser(userName: string, reason: string, clientid?: string, removeMessages?: number): void { - ModeratorCommands.warnUser(userName, reason, clientid, removeMessages); - } -} diff --git a/webclient/src/api/request/RoomsRequestImpl.ts b/webclient/src/api/request/RoomsRequestImpl.ts deleted file mode 100644 index 62b1f4e9b..000000000 --- a/webclient/src/api/request/RoomsRequestImpl.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { IRoomsRequest } from '@app/websocket'; -import { RoomCommands, SessionCommands } from '@app/websocket'; - -export class RoomsRequestImpl implements IRoomsRequest { - joinRoom(roomId: number): void { - SessionCommands.joinRoom(roomId); - } - - leaveRoom(roomId: number): void { - RoomCommands.leaveRoom(roomId); - } - - roomSay(roomId: number, message: string): void { - RoomCommands.roomSay(roomId, message); - } -} diff --git a/webclient/src/api/request/SessionRequestImpl.ts b/webclient/src/api/request/SessionRequestImpl.ts deleted file mode 100644 index c7b0e267a..000000000 --- a/webclient/src/api/request/SessionRequestImpl.ts +++ /dev/null @@ -1,52 +0,0 @@ -import type { ISessionRequest } from '@app/websocket'; -import { SessionCommands } from '@app/websocket'; - -export class SessionRequestImpl implements ISessionRequest { - addToBuddyList(userName: string): void { - SessionCommands.addToBuddyList(userName); - } - - removeFromBuddyList(userName: string): void { - SessionCommands.removeFromBuddyList(userName); - } - - addToIgnoreList(userName: string): void { - SessionCommands.addToIgnoreList(userName); - } - - removeFromIgnoreList(userName: string): void { - SessionCommands.removeFromIgnoreList(userName); - } - - changeAccountPassword(oldPassword: string, newPassword: string, hashedNewPassword?: string): void { - SessionCommands.accountPassword(oldPassword, newPassword, hashedNewPassword); - } - - changeAccountDetails(passwordCheck: string, realName?: string, email?: string, country?: string): void { - SessionCommands.accountEdit(passwordCheck, realName, email, country); - } - - changeAccountImage(image: Uint8Array): void { - SessionCommands.accountImage(image); - } - - sendDirectMessage(userName: string, message: string): void { - SessionCommands.message(userName, message); - } - - getUserInfo(userName: string): void { - SessionCommands.getUserInfo(userName); - } - - getUserGames(userName: string): void { - SessionCommands.getGamesOfUser(userName); - } - - deckDownload(deckId: number): void { - SessionCommands.deckDownload(deckId); - } - - replayDownload(replayId: number): void { - SessionCommands.replayDownload(replayId); - } -} diff --git a/webclient/src/api/request/index.ts b/webclient/src/api/request/index.ts deleted file mode 100644 index b5934d72a..000000000 --- a/webclient/src/api/request/index.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { IWebClientRequest } from '@app/websocket'; - -import { AuthenticationRequestImpl } from './AuthenticationRequestImpl'; -import { SessionRequestImpl } from './SessionRequestImpl'; -import { RoomsRequestImpl } from './RoomsRequestImpl'; -import { GameRequestImpl } from './GameRequestImpl'; -import { AdminRequestImpl } from './AdminRequestImpl'; -import { ModeratorRequestImpl } from './ModeratorRequestImpl'; - -export { AuthenticationRequestImpl, SessionRequestImpl, RoomsRequestImpl, GameRequestImpl, AdminRequestImpl, ModeratorRequestImpl }; - -export function createWebClientRequest(): IWebClientRequest { - return { - authentication: new AuthenticationRequestImpl(), - session: new SessionRequestImpl(), - rooms: new RoomsRequestImpl(), - game: new GameRequestImpl(), - admin: new AdminRequestImpl(), - moderator: new ModeratorRequestImpl(), - }; -} diff --git a/webclient/src/api/response/AdminResponseImpl.ts b/webclient/src/api/response/AdminResponseImpl.ts deleted file mode 100644 index a47b0f40b..000000000 --- a/webclient/src/api/response/AdminResponseImpl.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { IAdminResponse } from '@app/websocket'; -import { ServerDispatch } from '@app/store'; - -export class AdminResponseImpl implements IAdminResponse { - adjustMod(userName: string, shouldBeMod: boolean, shouldBeJudge: boolean): void { - ServerDispatch.adjustMod(userName, shouldBeMod, shouldBeJudge); - } - - reloadConfig(): void { - ServerDispatch.reloadConfig(); - } - - shutdownServer(): void { - ServerDispatch.shutdownServer(); - } - - updateServerMessage(): void { - ServerDispatch.updateServerMessage(); - } -} diff --git a/webclient/src/api/response/GameResponseImpl.ts b/webclient/src/api/response/GameResponseImpl.ts deleted file mode 100644 index cb8b9fef7..000000000 --- a/webclient/src/api/response/GameResponseImpl.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { Data } from '@app/types'; -import type { IGameResponse } from '@app/websocket'; -import { GameDispatch } from '@app/store'; - -export class GameResponseImpl implements IGameResponse { - clearStore(): void { - GameDispatch.clearStore(); - } - - gameStateChanged(gameId: number, data: Data.Event_GameStateChanged): void { - GameDispatch.gameStateChanged(gameId, data); - } - - playerJoined(gameId: number, playerProperties: Data.ServerInfo_PlayerProperties): void { - GameDispatch.playerJoined(gameId, playerProperties); - } - - playerLeft(gameId: number, playerId: number, reason: number): void { - GameDispatch.playerLeft(gameId, playerId, reason); - } - - playerPropertiesChanged(gameId: number, playerId: number, properties: Data.ServerInfo_PlayerProperties): void { - GameDispatch.playerPropertiesChanged(gameId, playerId, properties); - } - - gameClosed(gameId: number): void { - GameDispatch.gameClosed(gameId); - } - - gameHostChanged(gameId: number, hostId: number): void { - GameDispatch.gameHostChanged(gameId, hostId); - } - - kicked(gameId: number): void { - GameDispatch.kicked(gameId); - } - - gameSay(gameId: number, playerId: number, message: string): void { - GameDispatch.gameSay(gameId, playerId, message); - } - - cardMoved(gameId: number, playerId: number, data: Data.Event_MoveCard): void { - GameDispatch.cardMoved(gameId, playerId, data); - } - - cardFlipped(gameId: number, playerId: number, data: Data.Event_FlipCard): void { - GameDispatch.cardFlipped(gameId, playerId, data); - } - - cardDestroyed(gameId: number, playerId: number, data: Data.Event_DestroyCard): void { - GameDispatch.cardDestroyed(gameId, playerId, data); - } - - cardAttached(gameId: number, playerId: number, data: Data.Event_AttachCard): void { - GameDispatch.cardAttached(gameId, playerId, data); - } - - tokenCreated(gameId: number, playerId: number, data: Data.Event_CreateToken): void { - GameDispatch.tokenCreated(gameId, playerId, data); - } - - cardAttrChanged(gameId: number, playerId: number, data: Data.Event_SetCardAttr): void { - GameDispatch.cardAttrChanged(gameId, playerId, data); - } - - cardCounterChanged(gameId: number, playerId: number, data: Data.Event_SetCardCounter): void { - GameDispatch.cardCounterChanged(gameId, playerId, data); - } - - arrowCreated(gameId: number, playerId: number, data: Data.Event_CreateArrow): void { - GameDispatch.arrowCreated(gameId, playerId, data); - } - - arrowDeleted(gameId: number, playerId: number, data: Data.Event_DeleteArrow): void { - GameDispatch.arrowDeleted(gameId, playerId, data); - } - - counterCreated(gameId: number, playerId: number, data: Data.Event_CreateCounter): void { - GameDispatch.counterCreated(gameId, playerId, data); - } - - counterSet(gameId: number, playerId: number, data: Data.Event_SetCounter): void { - GameDispatch.counterSet(gameId, playerId, data); - } - - counterDeleted(gameId: number, playerId: number, data: Data.Event_DelCounter): void { - GameDispatch.counterDeleted(gameId, playerId, data); - } - - cardsDrawn(gameId: number, playerId: number, data: Data.Event_DrawCards): void { - GameDispatch.cardsDrawn(gameId, playerId, data); - } - - cardsRevealed(gameId: number, playerId: number, data: Data.Event_RevealCards): void { - GameDispatch.cardsRevealed(gameId, playerId, data); - } - - zoneShuffled(gameId: number, playerId: number, data: Data.Event_Shuffle): void { - GameDispatch.zoneShuffled(gameId, playerId, data); - } - - dieRolled(gameId: number, playerId: number, data: Data.Event_RollDie): void { - GameDispatch.dieRolled(gameId, playerId, data); - } - - activePlayerSet(gameId: number, activePlayerId: number): void { - GameDispatch.activePlayerSet(gameId, activePlayerId); - } - - activePhaseSet(gameId: number, phase: number): void { - GameDispatch.activePhaseSet(gameId, phase); - } - - turnReversed(gameId: number, reversed: boolean): void { - GameDispatch.turnReversed(gameId, reversed); - } - - zoneDumped(gameId: number, playerId: number, data: Data.Event_DumpZone): void { - GameDispatch.zoneDumped(gameId, playerId, data); - } - - zonePropertiesChanged(gameId: number, playerId: number, data: Data.Event_ChangeZoneProperties): void { - GameDispatch.zonePropertiesChanged(gameId, playerId, data); - } -} diff --git a/webclient/src/api/response/ModeratorResponseImpl.ts b/webclient/src/api/response/ModeratorResponseImpl.ts deleted file mode 100644 index c855152af..000000000 --- a/webclient/src/api/response/ModeratorResponseImpl.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { Data } from '@app/types'; -import type { IModeratorResponse } from '@app/websocket'; -import { ServerDispatch } from '@app/store'; - -export class ModeratorResponseImpl implements IModeratorResponse { - banFromServer(userName: string): void { - ServerDispatch.banFromServer(userName); - } - - banHistory(userName: string, banHistory: Data.ServerInfo_Ban[]): void { - ServerDispatch.banHistory(userName, banHistory); - } - - viewLogs(logs: Data.ServerInfo_ChatMessage[]): void { - ServerDispatch.viewLogs(logs); - } - - warnHistory(userName: string, warnHistory: Data.ServerInfo_Warning[]): void { - ServerDispatch.warnHistory(userName, warnHistory); - } - - warnListOptions(warnList: Data.Response_WarnList[]): void { - ServerDispatch.warnListOptions(warnList); - } - - warnUser(userName: string): void { - ServerDispatch.warnUser(userName); - } - - grantReplayAccess(replayId: number, moderatorName: string): void { - ServerDispatch.grantReplayAccess(replayId, moderatorName); - } - - forceActivateUser(usernameToActivate: string, moderatorName: string): void { - ServerDispatch.forceActivateUser(usernameToActivate, moderatorName); - } - - getAdminNotes(userName: string, notes: string): void { - ServerDispatch.getAdminNotes(userName, notes); - } - - updateAdminNotes(userName: string, notes: string): void { - ServerDispatch.updateAdminNotes(userName, notes); - } -} diff --git a/webclient/src/api/response/RoomResponseImpl.ts b/webclient/src/api/response/RoomResponseImpl.ts deleted file mode 100644 index 1a11bc4c5..000000000 --- a/webclient/src/api/response/RoomResponseImpl.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { Data, Enriched } from '@app/types'; -import type { IRoomResponse } from '@app/websocket'; -import { RoomsDispatch } from '@app/store'; - -export class RoomResponseImpl implements IRoomResponse { - clearStore(): void { - RoomsDispatch.clearStore(); - } - - joinRoom(roomInfo: Data.ServerInfo_Room): void { - RoomsDispatch.joinRoom(roomInfo); - } - - leaveRoom(roomId: number): void { - RoomsDispatch.leaveRoom(roomId); - } - - updateRooms(rooms: Data.ServerInfo_Room[]): void { - RoomsDispatch.updateRooms(rooms); - } - - updateGames(roomId: number, gameList: Data.ServerInfo_Game[]): void { - RoomsDispatch.updateGames(roomId, gameList); - } - - addMessage(roomId: number, message: Enriched.Message): void { - RoomsDispatch.addMessage(roomId, message); - } - - userJoined(roomId: number, user: Data.ServerInfo_User): void { - RoomsDispatch.userJoined(roomId, user); - } - - userLeft(roomId: number, name: string): void { - RoomsDispatch.userLeft(roomId, name); - } - - removeMessages(roomId: number, name: string, amount: number): void { - RoomsDispatch.removeMessages(roomId, name, amount); - } - - gameCreated(roomId: number): void { - RoomsDispatch.gameCreated(roomId); - } - - joinedGame(roomId: number, gameId: number): void { - RoomsDispatch.joinedGame(roomId, gameId); - } -} diff --git a/webclient/src/api/response/SessionResponseImpl.ts b/webclient/src/api/response/SessionResponseImpl.ts deleted file mode 100644 index 7d6c16b47..000000000 --- a/webclient/src/api/response/SessionResponseImpl.ts +++ /dev/null @@ -1,240 +0,0 @@ -import { App, Data, Enriched } from '@app/types'; -import type { ISessionResponse } from '@app/websocket'; -import { GameDispatch, RoomsDispatch, ServerDispatch } from '@app/store'; - -export class SessionResponseImpl implements ISessionResponse { - initialized(): void { - ServerDispatch.initialized(); - } - - connectionAttempted(): void { - ServerDispatch.connectionAttempted(); - } - - clearStore(): void { - ServerDispatch.clearStore(); - } - - loginSuccessful(options: Enriched.LoginSuccessContext): void { - ServerDispatch.loginSuccessful(options); - } - - loginFailed(): void { - ServerDispatch.loginFailed(); - } - - connectionFailed(): void { - ServerDispatch.connectionFailed(); - } - - testConnectionSuccessful(): void { - ServerDispatch.testConnectionSuccessful(); - } - - testConnectionFailed(): void { - ServerDispatch.testConnectionFailed(); - } - - updateBuddyList(buddyList: Data.ServerInfo_User[]): void { - ServerDispatch.updateBuddyList(buddyList); - } - - addToBuddyList(user: Data.ServerInfo_User): void { - ServerDispatch.addToBuddyList(user); - } - - removeFromBuddyList(userName: string): void { - ServerDispatch.removeFromBuddyList(userName); - } - - updateIgnoreList(ignoreList: Data.ServerInfo_User[]): void { - ServerDispatch.updateIgnoreList(ignoreList); - } - - addToIgnoreList(user: Data.ServerInfo_User): void { - ServerDispatch.addToIgnoreList(user); - } - - removeFromIgnoreList(userName: string): void { - ServerDispatch.removeFromIgnoreList(userName); - } - - updateInfo(name: string, version: string): void { - ServerDispatch.updateInfo(name, version); - } - - updateStatus(state: App.StatusEnum, description: string): void { - if (state === App.StatusEnum.DISCONNECTED) { - GameDispatch.clearStore(); - RoomsDispatch.clearStore(); - ServerDispatch.clearStore(); - } - ServerDispatch.updateStatus(state, description); - } - - updateUser(user: Data.ServerInfo_User): void { - ServerDispatch.updateUser(user); - } - - updateUsers(users: Data.ServerInfo_User[]): void { - ServerDispatch.updateUsers(users); - } - - userJoined(user: Data.ServerInfo_User): void { - ServerDispatch.userJoined(user); - } - - userLeft(userName: string): void { - ServerDispatch.userLeft(userName); - } - - serverMessage(message: string): void { - ServerDispatch.serverMessage(message); - } - - accountAwaitingActivation(options: Enriched.PendingActivationContext): void { - ServerDispatch.accountAwaitingActivation(options); - } - - accountActivationSuccess(): void { - ServerDispatch.accountActivationSuccess(); - } - - accountActivationFailed(): void { - ServerDispatch.accountActivationFailed(); - } - - registrationRequiresEmail(): void { - ServerDispatch.registrationRequiresEmail(); - } - - registrationSuccess(): void { - ServerDispatch.registrationSuccess(); - } - - registrationFailed(reason: string, endTime?: number): void { - ServerDispatch.registrationFailed(reason, endTime); - } - - registrationEmailError(error: string): void { - ServerDispatch.registrationEmailError(error); - } - - registrationPasswordError(error: string): void { - ServerDispatch.registrationPasswordError(error); - } - - registrationUserNameError(error: string): void { - ServerDispatch.registrationUserNameError(error); - } - - resetPasswordChallenge(): void { - ServerDispatch.resetPasswordChallenge(); - } - - resetPassword(): void { - ServerDispatch.resetPassword(); - } - - resetPasswordSuccess(): void { - ServerDispatch.resetPasswordSuccess(); - } - - resetPasswordFailed(): void { - ServerDispatch.resetPasswordFailed(); - } - - accountPasswordChange(): void { - ServerDispatch.accountPasswordChange(); - } - - accountEditChanged(realName?: string, email?: string, country?: string): void { - ServerDispatch.accountEditChanged({ realName, email, country }); - } - - accountImageChanged(avatarBmp: Uint8Array): void { - ServerDispatch.accountImageChanged({ avatarBmp }); - } - - getUserInfo(userInfo: Data.ServerInfo_User): void { - ServerDispatch.getUserInfo(userInfo); - } - - getGamesOfUser(userName: string, response: Data.Response_GetGamesOfUser): void { - ServerDispatch.gamesOfUser(userName, response); - } - - gameJoined(gameJoinedData: Data.Event_GameJoined): void { - GameDispatch.gameJoined(gameJoinedData); - } - - notifyUser(notification: Data.Event_NotifyUser): void { - ServerDispatch.notifyUser(notification); - } - - playerPropertiesChanged(gameId: number, playerId: number, payload: Data.Event_PlayerPropertiesChanged): void { - if (payload.playerProperties) { - GameDispatch.playerPropertiesChanged(gameId, playerId, payload.playerProperties); - } - } - - serverShutdown(data: Data.Event_ServerShutdown): void { - ServerDispatch.serverShutdown(data); - } - - userMessage(messageData: Data.Event_UserMessage): void { - ServerDispatch.userMessage(messageData); - } - - addToList(list: string, userName: string): void { - ServerDispatch.addToList(list, userName); - } - - removeFromList(list: string, userName: string): void { - ServerDispatch.removeFromList(list, userName); - } - - deleteServerDeck(deckId: number): void { - ServerDispatch.deckDelete(deckId); - } - - updateServerDecks(deckList: Data.Response_DeckList): void { - ServerDispatch.backendDecks(deckList); - } - - uploadServerDeck(path: string, treeItem: Data.ServerInfo_DeckStorage_TreeItem): void { - ServerDispatch.deckUpload(path, treeItem); - } - - createServerDeckDir(path: string, dirName: string): void { - ServerDispatch.deckNewDir(path, dirName); - } - - deleteServerDeckDir(path: string): void { - ServerDispatch.deckDelDir(path); - } - - replayList(matchList: Data.ServerInfo_ReplayMatch[]): void { - ServerDispatch.replayList(matchList); - } - - replayAdded(matchInfo: Data.ServerInfo_ReplayMatch): void { - ServerDispatch.replayAdded(matchInfo); - } - - replayModifyMatch(gameId: number, doNotHide: boolean): void { - ServerDispatch.replayModifyMatch(gameId, doNotHide); - } - - replayDeleteMatch(gameId: number): void { - ServerDispatch.replayDeleteMatch(gameId); - } - - downloadServerDeck(deckId: number, response: Data.Response_DeckDownload): void { - ServerDispatch.deckDownloaded(deckId, response.deck); - } - - replayDownloaded(replayId: number, response: Data.Response_ReplayDownload): void { - ServerDispatch.replayDownloaded(replayId, response.replayData); - } -} diff --git a/webclient/src/api/response/index.ts b/webclient/src/api/response/index.ts deleted file mode 100644 index b37b343ab..000000000 --- a/webclient/src/api/response/index.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { IWebClientResponse } from '@app/websocket'; - -import { SessionResponseImpl } from './SessionResponseImpl'; -import { RoomResponseImpl } from './RoomResponseImpl'; -import { GameResponseImpl } from './GameResponseImpl'; -import { AdminResponseImpl } from './AdminResponseImpl'; -import { ModeratorResponseImpl } from './ModeratorResponseImpl'; - -export { SessionResponseImpl, RoomResponseImpl, GameResponseImpl, AdminResponseImpl, ModeratorResponseImpl }; - -export function createWebClientResponse(): IWebClientResponse { - return { - session: new SessionResponseImpl(), - room: new RoomResponseImpl(), - game: new GameResponseImpl(), - admin: new AdminResponseImpl(), - moderator: new ModeratorResponseImpl(), - }; -} diff --git a/webclient/src/components/Guard/AuthGuard.tsx b/webclient/src/components/Guard/AuthGuard.tsx index 4bbb8e1d5..bf1ff1876 100644 --- a/webclient/src/components/Guard/AuthGuard.tsx +++ b/webclient/src/components/Guard/AuthGuard.tsx @@ -1,12 +1,14 @@ import React from 'react'; import { Navigate } from 'react-router-dom'; -import { ServerSelectors, useAppSelector } from '@app/store'; +import { ServerSelectors } from '@app/store'; import { App } from '@app/types'; +import { useAppSelector } from '@app/store'; +import { AuthenticationService } from '@app/api'; const AuthGuard = () => { - const isConnected = useAppSelector(ServerSelectors.getIsConnected); - return !isConnected + const state = useAppSelector(s => ServerSelectors.getState(s)); + return !AuthenticationService.isConnected(state) ? :
; }; diff --git a/webclient/src/components/Guard/ModGuard.tsx b/webclient/src/components/Guard/ModGuard.tsx index 96844b436..18b68adf1 100644 --- a/webclient/src/components/Guard/ModGuard.tsx +++ b/webclient/src/components/Guard/ModGuard.tsx @@ -1,12 +1,14 @@ import React from 'react'; import { Navigate } from 'react-router-dom'; -import { ServerSelectors, useAppSelector } from '@app/store'; +import { ServerSelectors } from '@app/store'; +import { AuthenticationService } from '@app/api'; import { App } from '@app/types'; +import { useAppSelector } from '@app/store'; const ModGuard = () => { - const isModerator = useAppSelector(ServerSelectors.getIsUserModerator); - return !isModerator + const user = useAppSelector(state => ServerSelectors.getUser(state)); + return !AuthenticationService.isModerator(user) ? : <>; }; diff --git a/webclient/src/components/KnownHosts/KnownHosts.tsx b/webclient/src/components/KnownHosts/KnownHosts.tsx index 3efdf67ac..096a8bece 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 { AuthenticationService } from '@app/api'; import { KnownHostDialog } from '@app/dialogs'; import { useReduxEffect } from '@app/hooks'; import { HostDTO } from '@app/services'; @@ -197,7 +197,7 @@ const KnownHosts = (props) => { setTestingConnection(TestConnection.TESTING); const options = { ...App.getHostPort(hostsState.selectedHost) }; - request.authentication.testConnection(options); + AuthenticationService.testConnection(options); } return ( diff --git a/webclient/src/components/UserDisplay/UserDisplay.tsx b/webclient/src/components/UserDisplay/UserDisplay.tsx index 6e38fab5c..a19bc9912 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 { SessionService } from '@app/api'; import { ServerSelectors } from '@app/store'; import { App, Data } from '@app/types'; import { useAppSelector } from '@app/store'; @@ -28,23 +28,23 @@ const UserDisplay = ({ user }: UserDisplayProps) => { const handleClose = () => setPosition(null); - const isABuddy = Boolean(buddyList[user.name]); - const isIgnored = Boolean(ignoreList[user.name]); + const isABuddy = buddyList.filter(u => u.name === user.name).length; + const isIgnored = ignoreList.filter(u => u.name === user.name).length; const onAddBuddy = () => { - request.session.addToBuddyList(user.name); + SessionService.addToBuddyList(user.name); handleClose(); }; const onRemoveBuddy = () => { - request.session.removeFromBuddyList(user.name); + SessionService.removeFromBuddyList(user.name); handleClose(); }; const onAddIgnore = () => { - request.session.addToIgnoreList(user.name); + SessionService.addToIgnoreList(user.name); handleClose(); }; const onRemoveIgnore = () => { - request.session.removeFromIgnoreList(user.name); + SessionService.removeFromIgnoreList(user.name); handleClose(); }; diff --git a/webclient/src/containers/Account/Account.tsx b/webclient/src/containers/Account/Account.tsx index 05d0b1161..8e0088d06 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 { AuthenticationService, SessionService } from '@app/api'; import { ServerSelectors } from '@app/store'; import Layout from '../Layout/Layout'; import { useAppSelector } from '@app/store'; @@ -18,8 +18,8 @@ import AddToIgnore from './AddToIgnore'; import './Account.css'; const Account = () => { - const buddyList = useAppSelector(state => ServerSelectors.getSortedBuddyList(state)); - const ignoreList = useAppSelector(state => ServerSelectors.getSortedIgnoreList(state)); + const buddyList = useAppSelector(state => ServerSelectors.getBuddyList(state)); + const ignoreList = useAppSelector(state => ServerSelectors.getIgnoreList(state)); const serverName = useAppSelector(state => ServerSelectors.getName(state)); const serverVersion = useAppSelector(state => ServerSelectors.getVersion(state)); const user = useAppSelector(state => ServerSelectors.getUser(state)); @@ -29,11 +29,11 @@ const Account = () => { const { t } = useTranslation(); const handleAddToBuddies = ({ userName }) => { - request.session.addToBuddyList(userName); + SessionService.addToBuddyList(userName); }; const handleAddToIgnore = ({ userName }) => { - request.session.addToIgnoreList(userName); + SessionService.addToIgnoreList(userName); }; return ( @@ -91,13 +91,7 @@ const Account = () => {

Server Name: {serverName}

Server Version: {serverVersion}

- +
diff --git a/webclient/src/containers/Layout/LeftNav.tsx b/webclient/src/containers/Layout/LeftNav.tsx index b9b2d377c..0730011d5 100644 --- a/webclient/src/containers/Layout/LeftNav.tsx +++ b/webclient/src/containers/Layout/LeftNav.tsx @@ -8,7 +8,7 @@ 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 { AuthenticationService, RoomsService } from '@app/api'; import { CardImportDialog } from '@app/dialogs'; import { Images } from '@app/images'; import { RoomsSelectors, ServerSelectors } from '@app/store'; @@ -25,8 +25,8 @@ interface LeftNavState { const LeftNav = () => { const joinedRooms = useAppSelector(state => RoomsSelectors.getJoinedRooms(state)); - const isConnected = useAppSelector(ServerSelectors.getIsConnected); - const isModerator = useAppSelector(ServerSelectors.getIsUserModerator); + const serverState = useAppSelector(state => ServerSelectors.getState(state)); + const user = useAppSelector(state => ServerSelectors.getUser(state)); const navigate = useNavigate(); const [state, setState] = useState({ anchorEl: null, @@ -40,7 +40,7 @@ const LeftNav = () => { 'Replays', ]; - if (isModerator) { + if (user && AuthenticationService.isModerator(user)) { options = [ ...options, 'Administration', @@ -49,7 +49,7 @@ const LeftNav = () => { } setState(s => ({ ...s, options })); - }, [isModerator]); + }, [user]); const handleMenuOpen = (event) => { setState(s => ({ ...s, anchorEl: event.target })); @@ -66,7 +66,7 @@ const LeftNav = () => { const leaveRoom = (event, roomId) => { event.preventDefault(); - request.rooms.leaveRoom(roomId); + RoomsService.leaveRoom(roomId); }; const openImportCardWizard = () => { @@ -85,11 +85,11 @@ const LeftNav = () => { logo - { isConnected && ( + { AuthenticationService.isConnected(serverState) && ( ) }
- { isConnected && ( + { AuthenticationService.isConnected(serverState) && (