diff --git a/webclient/eslint.boundaries.mjs b/webclient/eslint.boundaries.mjs new file mode 100644 index 000000000..0d67c6ae9 --- /dev/null +++ b/webclient/eslint.boundaries.mjs @@ -0,0 +1,55 @@ +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 f5a46a5a7..28a00a0ae 100644 --- a/webclient/eslint.config.mjs +++ b/webclient/eslint.config.mjs @@ -1,6 +1,7 @@ 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 @@ -12,6 +13,9 @@ 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 new file mode 100644 index 000000000..fb852b565 --- /dev/null +++ b/webclient/integration/src/authentication.spec.ts @@ -0,0 +1,136 @@ +// 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 new file mode 100644 index 000000000..d81c35f49 --- /dev/null +++ b/webclient/integration/src/connection.spec.ts @@ -0,0 +1,108 @@ +// 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 new file mode 100644 index 000000000..0b48f4df6 --- /dev/null +++ b/webclient/integration/src/helpers/command-capture.ts @@ -0,0 +1,112 @@ +// 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 new file mode 100644 index 000000000..8081a2b87 --- /dev/null +++ b/webclient/integration/src/helpers/protobuf-builders.ts @@ -0,0 +1,125 @@ +// 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 new file mode 100644 index 000000000..99d351311 --- /dev/null +++ b/webclient/integration/src/helpers/setup.ts @@ -0,0 +1,182 @@ +// 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 new file mode 100644 index 000000000..521d4dce9 --- /dev/null +++ b/webclient/integration/src/keep-alive.spec.ts @@ -0,0 +1,65 @@ +// 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 new file mode 100644 index 000000000..7e9ed519f --- /dev/null +++ b/webclient/integration/src/rooms.spec.ts @@ -0,0 +1,140 @@ +// 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 new file mode 100644 index 000000000..faa42f11a --- /dev/null +++ b/webclient/integration/src/server-events.spec.ts @@ -0,0 +1,105 @@ +// 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 new file mode 100644 index 000000000..03005abba --- /dev/null +++ b/webclient/integration/src/users.spec.ts @@ -0,0 +1,120 @@ +// 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 new file mode 100644 index 000000000..8f35df166 --- /dev/null +++ b/webclient/integration/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "types": ["node"] + }, + "include": ["./**/*.ts"] +} diff --git a/webclient/package-lock.json b/webclient/package-lock.json index 9f48a49b2..ed7f1854a 100644 --- a/webclient/package-lock.json +++ b/webclient/package-lock.json @@ -23,7 +23,6 @@ "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", @@ -45,7 +44,6 @@ "@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", @@ -57,6 +55,8 @@ "@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,6 +262,23 @@ "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", @@ -1753,13 +1770,6 @@ "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", @@ -2090,6 +2100,288 @@ "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", @@ -2417,6 +2709,19 @@ "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", @@ -2436,6 +2741,39 @@ "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", @@ -2445,6 +2783,26 @@ "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", @@ -2731,6 +3089,137 @@ } } }, + "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", @@ -2935,6 +3424,19 @@ "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", @@ -3043,6 +3545,19 @@ "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", @@ -3076,6 +3591,38 @@ "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", @@ -3279,6 +3826,16 @@ "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", @@ -3317,6 +3874,16 @@ "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", @@ -3776,12 +4343,6 @@ "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", @@ -3859,6 +4420,33 @@ "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", @@ -3885,6 +4473,16 @@ "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", @@ -3910,6 +4508,22 @@ "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", @@ -3917,6 +4531,13 @@ "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", @@ -4436,6 +5057,16 @@ "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", @@ -4573,6 +5204,16 @@ "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", @@ -4702,6 +5343,19 @@ "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", @@ -4798,6 +5452,20 @@ "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", @@ -4825,6 +5493,41 @@ "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", @@ -5112,6 +5815,13 @@ "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 b2927025f..774cef831 100644 --- a/webclient/package.json +++ b/webclient/package.json @@ -9,10 +9,14 @@ "start": "vite", "preview": "vite preview", "test": "vitest run", + "test:coverage": "npm run test -- --coverage", "test:watch": "vitest", - "lint": "eslint src/", - "lint:fix": "eslint src/ --fix", - "golden": "npm run lint && npm run test", + "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", "prepare": "cd .. && husky", "translate": "node prebuild.js -i18nOnly", "proto:generate": "npx buf generate" @@ -33,7 +37,6 @@ "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", @@ -55,7 +58,6 @@ "@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", @@ -67,6 +69,8 @@ "@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 deleted file mode 100644 index 1964a8368..000000000 --- a/webclient/src/api/AdminService.spec.ts +++ /dev/null @@ -1,46 +0,0 @@ -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 deleted file mode 100644 index c280fca7b..000000000 --- a/webclient/src/api/AdminService.tsx +++ /dev/null @@ -1,19 +0,0 @@ -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 deleted file mode 100644 index 0b2dfbc1c..000000000 --- a/webclient/src/api/AuthenticationService.spec.ts +++ /dev/null @@ -1,166 +0,0 @@ -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 deleted file mode 100644 index bacc8e350..000000000 --- a/webclient/src/api/AuthenticationService.tsx +++ /dev/null @@ -1,50 +0,0 @@ -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 deleted file mode 100644 index f32a58d09..000000000 --- a/webclient/src/api/ModeratorService.spec.ts +++ /dev/null @@ -1,73 +0,0 @@ -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 deleted file mode 100644 index 2fa9e9019..000000000 --- a/webclient/src/api/ModeratorService.tsx +++ /dev/null @@ -1,29 +0,0 @@ -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 deleted file mode 100644 index 80ff8d4cd..000000000 --- a/webclient/src/api/RoomsService.spec.ts +++ /dev/null @@ -1,35 +0,0 @@ -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 deleted file mode 100644 index b2f9ddc4e..000000000 --- a/webclient/src/api/RoomsService.tsx +++ /dev/null @@ -1,15 +0,0 @@ -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 deleted file mode 100644 index 879d4c3ef..000000000 --- a/webclient/src/api/SessionService.spec.ts +++ /dev/null @@ -1,100 +0,0 @@ -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 deleted file mode 100644 index 129cdfc58..000000000 --- a/webclient/src/api/SessionService.tsx +++ /dev/null @@ -1,43 +0,0 @@ -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 c4f67092e..0acaf2d93 100644 --- a/webclient/src/api/index.ts +++ b/webclient/src/api/index.ts @@ -1,5 +1,35 @@ -export { AdminService } from './AdminService'; -export { AuthenticationService } from './AuthenticationService'; -export { ModeratorService } from './ModeratorService'; -export { RoomsService } from './RoomsService'; -export { SessionService } from './SessionService'; +import { WebClient } from '@app/websocket'; +import type { IWebClientRequest } from '@app/websocket'; + +export { createWebClientResponse } from './response'; +export { createWebClientRequest } from './request'; + +/** + * UI-facing request surface. Each property is a lazy getter that resolves + * `WebClient.instance` at call time, so consumers can import this before the + * singleton is bootstrapped — it only needs to exist by the first actual call. + * + * Prefer this over importing `WebClient` directly: it keeps UI code free of + * transport-layer names and makes `@app/websocket` an internal detail of the + * `api` layer. + */ +export const request: IWebClientRequest = { + get authentication() { + return WebClient.instance.request.authentication; + }, + get session() { + return WebClient.instance.request.session; + }, + get rooms() { + return WebClient.instance.request.rooms; + }, + get admin() { + return WebClient.instance.request.admin; + }, + get moderator() { + return WebClient.instance.request.moderator; + }, + get game() { + return WebClient.instance.request.game; + }, +}; diff --git a/webclient/src/api/request/AdminRequestImpl.ts b/webclient/src/api/request/AdminRequestImpl.ts new file mode 100644 index 000000000..5cc21695b --- /dev/null +++ b/webclient/src/api/request/AdminRequestImpl.ts @@ -0,0 +1,20 @@ +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 new file mode 100644 index 000000000..9157d736f --- /dev/null +++ b/webclient/src/api/request/AuthenticationRequestImpl.ts @@ -0,0 +1,37 @@ +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 new file mode 100644 index 000000000..e594118cc --- /dev/null +++ b/webclient/src/api/request/GameRequestImpl.ts @@ -0,0 +1,142 @@ +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 new file mode 100644 index 000000000..9f5c063da --- /dev/null +++ b/webclient/src/api/request/ModeratorRequestImpl.ts @@ -0,0 +1,37 @@ +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 new file mode 100644 index 000000000..62b1f4e9b --- /dev/null +++ b/webclient/src/api/request/RoomsRequestImpl.ts @@ -0,0 +1,16 @@ +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 new file mode 100644 index 000000000..c7b0e267a --- /dev/null +++ b/webclient/src/api/request/SessionRequestImpl.ts @@ -0,0 +1,52 @@ +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 new file mode 100644 index 000000000..b5934d72a --- /dev/null +++ b/webclient/src/api/request/index.ts @@ -0,0 +1,21 @@ +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 new file mode 100644 index 000000000..a47b0f40b --- /dev/null +++ b/webclient/src/api/response/AdminResponseImpl.ts @@ -0,0 +1,20 @@ +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 new file mode 100644 index 000000000..cb8b9fef7 --- /dev/null +++ b/webclient/src/api/response/GameResponseImpl.ts @@ -0,0 +1,125 @@ +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 new file mode 100644 index 000000000..c855152af --- /dev/null +++ b/webclient/src/api/response/ModeratorResponseImpl.ts @@ -0,0 +1,45 @@ +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 new file mode 100644 index 000000000..1a11bc4c5 --- /dev/null +++ b/webclient/src/api/response/RoomResponseImpl.ts @@ -0,0 +1,49 @@ +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 new file mode 100644 index 000000000..7d6c16b47 --- /dev/null +++ b/webclient/src/api/response/SessionResponseImpl.ts @@ -0,0 +1,240 @@ +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 new file mode 100644 index 000000000..b37b343ab --- /dev/null +++ b/webclient/src/api/response/index.ts @@ -0,0 +1,19 @@ +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 bf1ff1876..4bbb8e1d5 100644 --- a/webclient/src/components/Guard/AuthGuard.tsx +++ b/webclient/src/components/Guard/AuthGuard.tsx @@ -1,14 +1,12 @@ import React from 'react'; import { Navigate } from 'react-router-dom'; -import { ServerSelectors } from '@app/store'; +import { ServerSelectors, useAppSelector } from '@app/store'; import { App } from '@app/types'; -import { useAppSelector } from '@app/store'; -import { AuthenticationService } from '@app/api'; const AuthGuard = () => { - const state = useAppSelector(s => ServerSelectors.getState(s)); - return !AuthenticationService.isConnected(state) + const isConnected = useAppSelector(ServerSelectors.getIsConnected); + return !isConnected ? :
; }; diff --git a/webclient/src/components/Guard/ModGuard.tsx b/webclient/src/components/Guard/ModGuard.tsx index 18b68adf1..96844b436 100644 --- a/webclient/src/components/Guard/ModGuard.tsx +++ b/webclient/src/components/Guard/ModGuard.tsx @@ -1,14 +1,12 @@ import React from 'react'; import { Navigate } from 'react-router-dom'; -import { ServerSelectors } from '@app/store'; -import { AuthenticationService } from '@app/api'; +import { ServerSelectors, useAppSelector } from '@app/store'; import { App } from '@app/types'; -import { useAppSelector } from '@app/store'; const ModGuard = () => { - const user = useAppSelector(state => ServerSelectors.getUser(state)); - return !AuthenticationService.isModerator(user) + const isModerator = useAppSelector(ServerSelectors.getIsUserModerator); + return !isModerator ? : <>; }; diff --git a/webclient/src/components/KnownHosts/KnownHosts.tsx b/webclient/src/components/KnownHosts/KnownHosts.tsx index 096a8bece..3efdf67ac 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 { AuthenticationService } from '@app/api'; +import { request } 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) }; - AuthenticationService.testConnection(options); + request.authentication.testConnection(options); } return ( diff --git a/webclient/src/components/UserDisplay/UserDisplay.tsx b/webclient/src/components/UserDisplay/UserDisplay.tsx index a19bc9912..6e38fab5c 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 { SessionService } from '@app/api'; +import { request } 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 = buddyList.filter(u => u.name === user.name).length; - const isIgnored = ignoreList.filter(u => u.name === user.name).length; + const isABuddy = Boolean(buddyList[user.name]); + const isIgnored = Boolean(ignoreList[user.name]); const onAddBuddy = () => { - SessionService.addToBuddyList(user.name); + request.session.addToBuddyList(user.name); handleClose(); }; const onRemoveBuddy = () => { - SessionService.removeFromBuddyList(user.name); + request.session.removeFromBuddyList(user.name); handleClose(); }; const onAddIgnore = () => { - SessionService.addToIgnoreList(user.name); + request.session.addToIgnoreList(user.name); handleClose(); }; const onRemoveIgnore = () => { - SessionService.removeFromIgnoreList(user.name); + request.session.removeFromIgnoreList(user.name); handleClose(); }; diff --git a/webclient/src/containers/Account/Account.tsx b/webclient/src/containers/Account/Account.tsx index 8e0088d06..05d0b1161 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 { AuthenticationService, SessionService } from '@app/api'; +import { request } 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.getBuddyList(state)); - const ignoreList = useAppSelector(state => ServerSelectors.getIgnoreList(state)); + const buddyList = useAppSelector(state => ServerSelectors.getSortedBuddyList(state)); + const ignoreList = useAppSelector(state => ServerSelectors.getSortedIgnoreList(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 }) => { - SessionService.addToBuddyList(userName); + request.session.addToBuddyList(userName); }; const handleAddToIgnore = ({ userName }) => { - SessionService.addToIgnoreList(userName); + request.session.addToIgnoreList(userName); }; return ( @@ -91,7 +91,13 @@ 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 0730011d5..b9b2d377c 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 { AuthenticationService, RoomsService } from '@app/api'; +import { request } 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 serverState = useAppSelector(state => ServerSelectors.getState(state)); - const user = useAppSelector(state => ServerSelectors.getUser(state)); + const isConnected = useAppSelector(ServerSelectors.getIsConnected); + const isModerator = useAppSelector(ServerSelectors.getIsUserModerator); const navigate = useNavigate(); const [state, setState] = useState({ anchorEl: null, @@ -40,7 +40,7 @@ const LeftNav = () => { 'Replays', ]; - if (user && AuthenticationService.isModerator(user)) { + if (isModerator) { options = [ ...options, 'Administration', @@ -49,7 +49,7 @@ const LeftNav = () => { } setState(s => ({ ...s, options })); - }, [user]); + }, [isModerator]); const handleMenuOpen = (event) => { setState(s => ({ ...s, anchorEl: event.target })); @@ -66,7 +66,7 @@ const LeftNav = () => { const leaveRoom = (event, roomId) => { event.preventDefault(); - RoomsService.leaveRoom(roomId); + request.rooms.leaveRoom(roomId); }; const openImportCardWizard = () => { @@ -85,11 +85,11 @@ const LeftNav = () => { logo - { AuthenticationService.isConnected(serverState) && ( + { isConnected && ( ) }
- { AuthenticationService.isConnected(serverState) && ( + { isConnected && (