replace websocket with sockatrice

This commit is contained in:
seavor 2026-05-07 15:54:59 -05:00
parent 68050b56bc
commit e2319c84db
218 changed files with 89 additions and 9100 deletions

View file

@ -9,9 +9,7 @@
/plans /plans
# generated ./src files # generated ./src files
/src/proto-files.json
/src/server-props.json /src/server-props.json
/src/generated/
# testing # testing
/coverage /coverage

View file

@ -1,165 +0,0 @@
// @ts-check
/**
* Custom protoc-gen-es sibling plugin. Emits `src/generated/index.ts`, a
* single rollup that re-exports every generated `_pb` module and adds
* `MessageInitShape<T>` param aliases for every `Command_*` message.
*
* Wired into `buf.gen.yaml` as a second local plugin. Runs with the same
* descriptor set protoc-gen-es consumes, so output always tracks the protos.
*/
import { createEcmaScriptPlugin, runNodeJs } from '@bufbuild/protoplugin';
const HEADER = [
'// @generated by protoc-gen-data. DO NOT EDIT.',
'// Rollup of all proto modules + MessageInitShape param aliases for every Command_*,',
'// plus type maps for Response/Event extensions grouped by scope.',
'/* eslint-disable */',
'',
'',
].join('\n');
const inner = createEcmaScriptPlugin({
name: 'protoc-gen-data',
version: 'v0.1.0',
generateTs(schema) {
const f = schema.generateFile('index.ts');
const MessageInitShape = f.import('MessageInitShape', '@bufbuild/protobuf', true);
const MessageType = f.import('Message', '@bufbuild/protobuf', true);
const GenExtensionType = f.import('GenExtension', '@bufbuild/protobuf/codegenv2', true);
const sortedFiles = [...schema.files].sort((a, b) => a.name.localeCompare(b.name));
for (const file of sortedFiles) {
f.print('export * from ', f.string(`./proto/${file.name}_pb`), ';');
}
f.print();
const commandMessages = [];
for (const file of sortedFiles) {
for (const msg of file.messages) {
if (msg.name.startsWith('Command_')) {
commandMessages.push(msg);
}
}
}
commandMessages.sort((a, b) => a.name.localeCompare(b.name));
// importSchema() resolves paths relative to this plugin's `out` dir, which
// yields `./<name>_pb` — but the _pb files live under ./proto/ (protoc-gen-es's
// out). Build the import path explicitly so it points inside the proto subdir.
for (const msg of commandMessages) {
const alias = msg.name.slice('Command_'.length) + 'Params';
const schemaName = `${msg.name}Schema`;
const schemaSym = f.import(schemaName, `./proto/${msg.file.name}_pb`, true);
f.print('export type ', alias, ' = ', MessageInitShape, '<typeof ', schemaSym, '>;');
}
f.print();
// ── Type maps for Response/Event extensions, grouped by extendee ────────
//
// Scans all messages for nested `extend` declarations and groups them by
// which message they extend (Response, SessionEvent, RoomEvent, GameEvent).
// Emits one `interface *Map { TypeName: TypeName; ... }` per scope.
/** @type {Map<string, import('@bufbuild/protobuf').DescMessage[]>} */
const extendeeGroups = new Map();
for (const file of sortedFiles) {
for (const msg of file.messages) {
for (const ext of msg.nestedExtensions) {
const target = ext.extendee.name;
const group = extendeeGroups.get(target);
if (group) {
group.push(msg);
} else {
extendeeGroups.set(target, [msg]);
}
}
}
}
/** @type {[string, string, import('@bufbuild/protobuf').DescMessage | null][]} */
const maps = [
['ResponseMap', 'Response', null],
['SessionEventMap', 'SessionEvent', null],
['RoomEventMap', 'RoomEvent', null],
['GameEventMap', 'GameEvent', null],
];
// Resolve the base extendee message for maps that need the base type included
for (const file of sortedFiles) {
for (const msg of file.messages) {
for (const entry of maps) {
if (msg.name === entry[1]) {
entry[2] = msg;
}
}
}
}
for (const [mapName, extendeeName, baseMsg] of maps) {
const msgs = (extendeeGroups.get(extendeeName) || []).slice();
msgs.sort((a, b) => a.name.localeCompare(b.name));
if (msgs.length === 0 && !baseMsg) continue;
f.print('export interface ', mapName, ' {');
// Include the base extendee type itself (e.g. Response in ResponseMap)
if (baseMsg) {
const sym = f.import(baseMsg.name, `./proto/${baseMsg.file.name}_pb`, true);
f.print(' ', baseMsg.name, ': ', sym, ';');
}
for (const msg of msgs) {
const sym = f.import(msg.name, `./proto/${msg.file.name}_pb`, true);
f.print(' ', msg.name, ': ', sym, ';');
}
f.print('}');
f.print();
}
// Generic extension registry infrastructure. Consolidates the three
// near-duplicate registry types and helpers that used to live in
// src/websocket/services/protobuf-types.ts into one generic pair.
// Specialised aliases (Session/Room/Game) still live in protobuf-types.ts
// because GameExtensionRegistry needs GameEventMeta — a hand-written
// domain type whose import would create a generated/ ↔ types/ cycle.
f.print('export type RegistryEntry<V, T extends ', MessageType, ', M = unknown> = [');
f.print(' ', GenExtensionType, '<T, V>,');
f.print(' (value: V, meta: M) => void,');
f.print('];');
f.print();
// Return type widens V to `unknown` so the heterogeneous entries that
// callers build can be stored in a homogeneous `RegistryEntry<unknown, T, M>[]`
// array. This is the actual value-add over a bare tuple literal.
f.print('export function makeEntry<T extends ', MessageType, ', V, M = unknown>(');
f.print(' ext: ', GenExtensionType, '<T, V>,');
f.print(' handler: (value: V, meta: M) => void,');
f.print('): RegistryEntry<unknown, T, M> {');
f.print(' return [ext, handler] as unknown as RegistryEntry<unknown, T, M>;');
f.print('}');
},
});
// Skip f.preamble() above and inject a custom rollup-aware header here instead —
// preamble() would write "@generated from file X.proto" which is misleading for
// a rollup file built from every input proto.
/** @type {import('@bufbuild/protoplugin').Plugin} */
const plugin = {
name: inner.name,
version: inner.version,
run(request) {
const response = inner.run(request);
for (const file of response.file) {
if (file.name === 'index.ts' && typeof file.content === 'string') {
file.content = HEADER + file.content;
}
}
return response;
},
};
runNodeJs(plugin);

View file

@ -1,12 +0,0 @@
version: v2
inputs:
- directory: ../libcockatrice_protocol/libcockatrice/protocol/pb
plugins:
- local: protoc-gen-es
out: src/generated/proto
opt:
- target=ts
- local: [node, buf.gen.plugin.mjs]
out: src/generated
opt:
- target=ts

View file

@ -6,43 +6,37 @@ const elements = [
{ type: 'containers', pattern: ['src/containers/**'] }, { type: 'containers', pattern: ['src/containers/**'] },
{ type: 'dialogs', pattern: ['src/dialogs/**'] }, { type: 'dialogs', pattern: ['src/dialogs/**'] },
{ type: 'forms', pattern: ['src/forms/**'] }, { type: 'forms', pattern: ['src/forms/**'] },
{ type: 'generated', pattern: ['src/generated/**'] },
{ type: 'hooks', pattern: ['src/hooks/**'] }, { type: 'hooks', pattern: ['src/hooks/**'] },
{ type: 'images', pattern: ['src/images/**'] }, { type: 'images', pattern: ['src/images/**'] },
{ type: 'services', pattern: ['src/services/**'] }, { type: 'services', pattern: ['src/services/**'] },
{ type: 'store', pattern: ['src/store/**'] }, { type: 'store', pattern: ['src/store/**'] },
{ type: 'types', pattern: ['src/types/**'] }, { type: 'types', pattern: ['src/types/**'] },
{ type: 'utils', pattern: ['src/utils/**'] }, { type: 'utils', pattern: ['src/utils/**'] },
{ type: 'websocket-types', pattern: ['src/websocket/types/**'] },
{ type: 'websocket', pattern: ['src/websocket/**'] },
]; ];
const types = (...types) => types.map((type) => ({ to: { type } })); const types = (...types) => types.map((type) => ({ to: { type } }));
const rules = [ const rules = [
{ from: { type: 'generated' }, allow: [] }, { from: { type: 'types' }, allow: [] },
{ from: { type: 'websocket-types' }, allow: types('generated') },
{ from: { type: 'websocket' }, allow: types('generated', 'websocket-types') },
{ from: { type: 'types' }, allow: types('generated') },
{ from: { type: 'utils' }, allow: types('types') }, { from: { type: 'utils' }, allow: types('types') },
{ from: { type: 'store' }, allow: types('types', 'utils', 'websocket-types') }, { from: { type: 'store' }, allow: types('types', 'utils') },
{ from: { type: 'api' }, allow: types('store', 'types', 'utils', 'websocket', 'websocket-types') }, { from: { type: 'api' }, allow: types('store', 'types', 'utils') },
{ from: { type: 'images' }, allow: types('types') }, { from: { type: 'images' }, allow: types('types') },
{ from: { type: 'services' }, allow: types('api', 'store', 'types', 'utils') }, { from: { type: 'services' }, allow: types('api', 'store', 'types', 'utils') },
{ from: { type: 'hooks' }, allow: types('api', 'services', 'store', 'types', 'utils', 'websocket', 'websocket-types') }, { from: { type: 'hooks' }, allow: types('api', 'services', 'store', 'types', 'utils') },
{ {
from: { type: 'components' }, from: { type: 'components' },
allow: types('api', 'dialogs', 'forms', 'hooks', 'images', 'services', 'store', 'types', 'utils', 'websocket-types') allow: types('api', 'dialogs', 'forms', 'hooks', 'images', 'services', 'store', 'types', 'utils')
}, },
{ {
from: { type: 'containers' }, from: { type: 'containers' },
allow: types('api', 'components', 'dialogs', 'forms', 'hooks', 'images', 'services', 'store', 'types', 'utils', 'websocket-types') allow: types('api', 'components', 'dialogs', 'forms', 'hooks', 'images', 'services', 'store', 'types', 'utils')
}, },
{ from: { type: 'dialogs' }, allow: types('components', 'forms', 'hooks', 'services', 'store', 'types', 'utils', 'websocket-types') }, { from: { type: 'dialogs' }, allow: types('components', 'forms', 'hooks', 'services', 'store', 'types', 'utils') },
{ from: { type: 'forms' }, allow: types('components', 'hooks', 'services', 'store', 'types', 'utils', 'websocket-types') }, { from: { type: 'forms' }, allow: types('components', 'hooks', 'services', 'store', 'types', 'utils') },
]; ];
export const boundariesConfig = [ export const boundariesConfig = [

View file

@ -5,7 +5,7 @@ import { boundariesConfig } from './eslint.boundaries.mjs';
export default tseslint.config( export default tseslint.config(
// Global ignores // Global ignores
{ ignores: ['node_modules/**', 'build/**', 'public/pb/**', 'src/generated/**'] }, { ignores: ['node_modules/**', 'build/**'] },
// Base JS recommended // Base JS recommended
js.configs.recommended, js.configs.recommended,

View file

@ -21,8 +21,14 @@ import { ServerDispatch, RoomsDispatch, GameDispatch } from '@app/store';
import { Data } from '@app/types'; import { Data } from '@app/types';
import { WebClient, setPendingOptions } from '@app/websocket'; import { WebClient, setPendingOptions } from '@app/websocket';
import { WebsocketTypes } from '@app/websocket/types'; import { WebsocketTypes } from '@app/websocket/types';
import { PROTOCOL_VERSION } from '../../../src/websocket/config'; import {
import { createWebClientRequest, createWebClientResponse } from '@app/api'; CLIENT_CONFIG,
CLIENT_OPTIONS,
PROTOCOL_VERSION,
createWebClientResponse,
} from '@app/api';
export { PROTOCOL_VERSION };
import { import {
buildResponse, buildResponse,
@ -195,7 +201,12 @@ installMockWebSocket();
beforeEach(() => { beforeEach(() => {
vi.useFakeTimers(); vi.useFakeTimers();
new WebClient(createWebClientRequest(), createWebClientResponse()); new WebClient(
createWebClientResponse(),
CLIENT_CONFIG,
CLIENT_OPTIONS,
PROTOCOL_VERSION,
);
}); });
afterEach(() => { afterEach(() => {

View file

@ -9,14 +9,13 @@ import { Data } from '@app/types';
import { store } from '@app/store'; import { store } from '@app/store';
import { WebsocketTypes } from '@app/websocket/types'; import { WebsocketTypes } from '@app/websocket/types';
import { PROTOCOL_VERSION } from '../../../src/websocket/config';
import { import {
getMockWebSocket, getMockWebSocket,
getWebClient, getWebClient,
openMockWebSocket, openMockWebSocket,
setPendingOptions, setPendingOptions,
connectAndHandshake, connectAndHandshake,
PROTOCOL_VERSION,
} from '../helpers/setup'; } from '../helpers/setup';
import { import {
buildSessionEventMessage, buildSessionEventMessage,

View file

@ -18,7 +18,6 @@
"@reduxjs/toolkit": "^2.11.2", "@reduxjs/toolkit": "^2.11.2",
"crypto-js": "^4.2.0", "crypto-js": "^4.2.0",
"dexie": "^4.4.2", "dexie": "^4.4.2",
"dompurify": "^3.4.0",
"final-form": "^5.0.0", "final-form": "^5.0.0",
"final-form-set-field-touched": "^1.0.1", "final-form-set-field-touched": "^1.0.1",
"i18next": "^26.0.5", "i18next": "^26.0.5",
@ -35,18 +34,16 @@
"react-router-dom": "^7.14.1", "react-router-dom": "^7.14.1",
"react-virtualized-auto-sizer": "^2.0.3", "react-virtualized-auto-sizer": "^2.0.3",
"react-window": "^2.2.7", "react-window": "^2.2.7",
"rxjs": "^7.5.4" "rxjs": "^7.5.4",
"sockatrice": "https://github.com/seavor/Sockatrice/releases/download/v2.1.3/sockatrice-2.1.3.tgz"
}, },
"devDependencies": { "devDependencies": {
"@bufbuild/buf": "^1.68.1",
"@bufbuild/protoc-gen-es": "^2.11.0",
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@mui/types": "^9.0.0", "@mui/types": "^9.0.0",
"@playwright/test": "^1.51.0", "@playwright/test": "^1.51.0",
"@testing-library/dom": "^10.4.1", "@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.4.0", "@testing-library/jest-dom": "^6.4.0",
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.2",
"@types/dompurify": "^3.0.5",
"@types/node": "^22.19.17", "@types/node": "^22.19.17",
"@types/prop-types": "^15.7.4", "@types/prop-types": "^15.7.4",
"@types/react": "^19.0.0", "@types/react": "^19.0.0",
@ -299,207 +296,12 @@
"specificity": "bin/cli.js" "specificity": "bin/cli.js"
} }
}, },
"node_modules/@bufbuild/buf": {
"version": "1.68.1",
"resolved": "https://registry.npmjs.org/@bufbuild/buf/-/buf-1.68.1.tgz",
"integrity": "sha512-QDJ3oy4qZ5EVS2JYtmpE1n9FuaoABthxIddXB050huGddatr1sjHJSSAXXpLotOI18pW3KQ4zzU1x5Ms+pEEOw==",
"dev": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"bin": {
"buf": "bin/buf",
"protoc-gen-buf-breaking": "bin/protoc-gen-buf-breaking",
"protoc-gen-buf-lint": "bin/protoc-gen-buf-lint"
},
"engines": {
"node": ">=12"
},
"optionalDependencies": {
"@bufbuild/buf-darwin-arm64": "1.68.1",
"@bufbuild/buf-darwin-x64": "1.68.1",
"@bufbuild/buf-linux-aarch64": "1.68.1",
"@bufbuild/buf-linux-armv7": "1.68.1",
"@bufbuild/buf-linux-x64": "1.68.1",
"@bufbuild/buf-win32-arm64": "1.68.1",
"@bufbuild/buf-win32-x64": "1.68.1"
}
},
"node_modules/@bufbuild/buf-darwin-arm64": {
"version": "1.68.1",
"resolved": "https://registry.npmjs.org/@bufbuild/buf-darwin-arm64/-/buf-darwin-arm64-1.68.1.tgz",
"integrity": "sha512-+Cu/2Kr6Add3s+Zk/edcF9QdpnrsukQkdR/z4fk4+qr6YZqfWfiV8f+s14I3h7qPrPnGeCeynvmZ9NmJ1BMYuA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@bufbuild/buf-darwin-x64": {
"version": "1.68.1",
"resolved": "https://registry.npmjs.org/@bufbuild/buf-darwin-x64/-/buf-darwin-x64-1.68.1.tgz",
"integrity": "sha512-hvAs452aJ6io9hZKSfr3TvC+//16zW5y5u3ucsIXVkl5mkmKWSCkPbZwGpjNCfRGGUsyRJGL6rixxTgLKw2k5w==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@bufbuild/buf-linux-aarch64": {
"version": "1.68.1",
"resolved": "https://registry.npmjs.org/@bufbuild/buf-linux-aarch64/-/buf-linux-aarch64-1.68.1.tgz",
"integrity": "sha512-GLCakHzZVKUPlAiJEPGMBLW+yBk8tuz6NNcoeQU5lB5AO7ks8V8x9cy4CQjne4YSl3niF1JtvAQckLKhEWPueQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@bufbuild/buf-linux-armv7": {
"version": "1.68.1",
"resolved": "https://registry.npmjs.org/@bufbuild/buf-linux-armv7/-/buf-linux-armv7-1.68.1.tgz",
"integrity": "sha512-lUMCULl3MOYQe0oAPWnqNVYy8pL+F3Jeq6C4sSY+0E9udaACMc2mZ32gYingaMop9O1qS58HjJbezFxxL+CJqg==",
"cpu": [
"arm"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@bufbuild/buf-linux-x64": {
"version": "1.68.1",
"resolved": "https://registry.npmjs.org/@bufbuild/buf-linux-x64/-/buf-linux-x64-1.68.1.tgz",
"integrity": "sha512-eRU3UWiZQthAgx+qFTG3EeJ/VeOcZzAkKYGt5ansOnOIJHBm+3RG2KqA+Jm8q3EFqB1XpVcGxPXnIu/qmFJXaQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@bufbuild/buf-win32-arm64": {
"version": "1.68.1",
"resolved": "https://registry.npmjs.org/@bufbuild/buf-win32-arm64/-/buf-win32-arm64-1.68.1.tgz",
"integrity": "sha512-v3xlKzs3l2C+mYv+T0sYol05DTmsFKYmM5Vz8+AyrXdjxRwq2QH7m0arVWwxHX2MwyhQxKA+qqjoF8bCUM7xxA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@bufbuild/buf-win32-x64": {
"version": "1.68.1",
"resolved": "https://registry.npmjs.org/@bufbuild/buf-win32-x64/-/buf-win32-x64-1.68.1.tgz",
"integrity": "sha512-b62pwu+G7n5tF8n1QIoT85K7xgKJZS8SzdN020weOa7IVvMNHCDqMq7nrkz46fXCkK7MtD1YJ6sUp86sWSZPsw==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@bufbuild/protobuf": { "node_modules/@bufbuild/protobuf": {
"version": "2.11.0", "version": "2.11.0",
"resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.11.0.tgz", "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.11.0.tgz",
"integrity": "sha512-sBXGT13cpmPR5BMgHE6UEEfEaShh5Ror6rfN3yEK5si7QVrtZg8LEPQb0VVhiLRUslD2yLnXtnRzG035J/mZXQ==", "integrity": "sha512-sBXGT13cpmPR5BMgHE6UEEfEaShh5Ror6rfN3yEK5si7QVrtZg8LEPQb0VVhiLRUslD2yLnXtnRzG035J/mZXQ==",
"license": "(Apache-2.0 AND BSD-3-Clause)" "license": "(Apache-2.0 AND BSD-3-Clause)"
}, },
"node_modules/@bufbuild/protoc-gen-es": {
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/@bufbuild/protoc-gen-es/-/protoc-gen-es-2.11.0.tgz",
"integrity": "sha512-VzQuwEQDXipbZ1soWUuAWm1Z0C3B/IDWGeysnbX6ogJ6As91C2mdvAND/ekQ4YIWgen4d5nqLfIBOWLqCCjYUA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@bufbuild/protobuf": "2.11.0",
"@bufbuild/protoplugin": "2.11.0"
},
"bin": {
"protoc-gen-es": "bin/protoc-gen-es"
},
"engines": {
"node": ">=20"
},
"peerDependencies": {
"@bufbuild/protobuf": "2.11.0"
},
"peerDependenciesMeta": {
"@bufbuild/protobuf": {
"optional": true
}
}
},
"node_modules/@bufbuild/protoplugin": {
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/@bufbuild/protoplugin/-/protoplugin-2.11.0.tgz",
"integrity": "sha512-lyZVNFUHArIOt4W0+dwYBe5GBwbKzbOy8ObaloEqsw9Mmiwv2O48TwddDoHN4itylC+BaEGqFdI1W8WQt2vWJQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@bufbuild/protobuf": "2.11.0",
"@typescript/vfs": "^1.6.2",
"typescript": "5.4.5"
}
},
"node_modules/@bufbuild/protoplugin/node_modules/typescript": {
"version": "5.4.5",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz",
"integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/@csstools/color-helpers": { "node_modules/@csstools/color-helpers": {
"version": "6.0.2", "version": "6.0.2",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz",
@ -2063,16 +1865,6 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/dompurify": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz",
"integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/trusted-types": "*"
}
},
"node_modules/@types/esrecurse": { "node_modules/@types/esrecurse": {
"version": "4.3.1", "version": "4.3.1",
"resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
@ -2169,8 +1961,8 @@
"version": "2.0.7", "version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"devOptional": true, "license": "MIT",
"license": "MIT" "optional": true
}, },
"node_modules/@types/use-sync-external-store": { "node_modules/@types/use-sync-external-store": {
"version": "0.0.6", "version": "0.0.6",
@ -2421,19 +2213,6 @@
"url": "https://opencollective.com/eslint" "url": "https://opencollective.com/eslint"
} }
}, },
"node_modules/@typescript/vfs": {
"version": "1.6.4",
"resolved": "https://registry.npmjs.org/@typescript/vfs/-/vfs-1.6.4.tgz",
"integrity": "sha512-PJFXFS4ZJKiJ9Qiuix6Dz/OwEIqHD7Dme1UwZhTK11vR+5dqW2ACbdndWQexBzCx+CPuMe5WBYQWCsFyGlQLlQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"debug": "^4.4.3"
},
"peerDependencies": {
"typescript": "*"
}
},
"node_modules/@unrs/resolver-binding-android-arm-eabi": { "node_modules/@unrs/resolver-binding-android-arm-eabi": {
"version": "1.11.1", "version": "1.11.1",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz", "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz",
@ -5593,6 +5372,19 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/sockatrice": {
"version": "2.1.3",
"resolved": "https://github.com/seavor/Sockatrice/releases/download/v2.1.3/sockatrice-2.1.3.tgz",
"integrity": "sha512-xywXolumrums59NteojtdmVKfjF8Ah71ewseczecZBqSmNAQ6zhM9enhmukWLvgQJr0rh7o1RDX5m+54kct+vA==",
"license": "GPL-2.0-or-later",
"dependencies": {
"@bufbuild/protobuf": "^2.11.0",
"dompurify": "^3.4.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/source-map": { "node_modules/source-map": {
"version": "0.5.7", "version": "0.5.7",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",

View file

@ -25,11 +25,10 @@
"test:e2e:down": "docker compose -f e2e/docker/docker-compose.e2e.yml down -v", "test:e2e:down": "docker compose -f e2e/docker/docker-compose.e2e.yml down -v",
"test:e2e:install-browsers": "npx playwright install --with-deps chromium", "test:e2e:install-browsers": "npx playwright install --with-deps chromium",
"pretest:e2e": "npm run test:e2e:install-browsers", "pretest:e2e": "npm run test:e2e:install-browsers",
"prebuild": "npm run proto:generate && node prebuild.js", "prebuild": "node prebuild.js",
"prepare": "cd .. && husky", "prepare": "cd .. && husky",
"prestart": "npm run proto:generate && node prebuild.js", "prestart": "node prebuild.js",
"preview": "vite preview", "preview": "vite preview",
"proto:generate": "npx buf generate",
"start": "vite", "start": "vite",
"translate": "node prebuild.js -i18nOnly" "translate": "node prebuild.js -i18nOnly"
}, },
@ -44,7 +43,6 @@
"@reduxjs/toolkit": "^2.11.2", "@reduxjs/toolkit": "^2.11.2",
"crypto-js": "^4.2.0", "crypto-js": "^4.2.0",
"dexie": "^4.4.2", "dexie": "^4.4.2",
"dompurify": "^3.4.0",
"final-form": "^5.0.0", "final-form": "^5.0.0",
"final-form-set-field-touched": "^1.0.1", "final-form-set-field-touched": "^1.0.1",
"i18next": "^26.0.5", "i18next": "^26.0.5",
@ -61,11 +59,10 @@
"react-router-dom": "^7.14.1", "react-router-dom": "^7.14.1",
"react-virtualized-auto-sizer": "^2.0.3", "react-virtualized-auto-sizer": "^2.0.3",
"react-window": "^2.2.7", "react-window": "^2.2.7",
"rxjs": "^7.5.4" "rxjs": "^7.5.4",
"sockatrice": "https://github.com/seavor/Sockatrice/releases/download/v2.1.3/sockatrice-2.1.3.tgz"
}, },
"devDependencies": { "devDependencies": {
"@bufbuild/buf": "^1.68.1",
"@bufbuild/protoc-gen-es": "^2.11.0",
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@playwright/test": "^1.51.0", "@playwright/test": "^1.51.0",
"@types/ws": "^8.5.12", "@types/ws": "^8.5.12",
@ -73,7 +70,6 @@
"@testing-library/dom": "^10.4.1", "@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.4.0", "@testing-library/jest-dom": "^6.4.0",
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.2",
"@types/dompurify": "^3.0.5",
"@types/node": "^22.19.17", "@types/node": "^22.19.17",
"@types/prop-types": "^15.7.4", "@types/prop-types": "^15.7.4",
"@types/react": "^19.0.0", "@types/react": "^19.0.0",

View file

@ -1,4 +0,0 @@
# Ignore all files
*
# Except gitignore
!.gitignore

View file

@ -3,7 +3,7 @@ import { Provider } from 'react-redux';
import { configureStore, Reducer } from '@reduxjs/toolkit'; import { configureStore, Reducer } from '@reduxjs/toolkit';
import { WebClientContext } from '../hooks/useWebClient'; import { WebClientContext } from '../hooks/useWebClient';
import type { WebClient } from '../websocket'; import type { WebClient } from '@app/websocket';
import { createMockWebClient } from './mockWebClient'; import { createMockWebClient } from './mockWebClient';
// Minimal Provider wrapper for hook-only tests. Use this instead of // Minimal Provider wrapper for hook-only tests. Use this instead of

View file

@ -27,7 +27,7 @@ export function createMockWebClient() {
accountEdit: vi.fn(), accountEdit: vi.fn(),
accountPassword: vi.fn(), accountPassword: vi.fn(),
accountImage: vi.fn(), accountImage: vi.fn(),
listUsers: vi.fn(), message: vi.fn(),
}, },
rooms: { rooms: {
joinRoom: vi.fn(), joinRoom: vi.fn(),

View file

@ -37,7 +37,7 @@ const testTheme = createTheme({
}); });
import { WebClientContext } from '../hooks/useWebClient'; import { WebClientContext } from '../hooks/useWebClient';
import type { WebClient } from '../websocket'; import type { WebClient } from '@app/websocket';
import rootReducer from '../store/rootReducer'; import rootReducer from '../store/rootReducer';
import { ToastProvider } from '../components/Toast/ToastContext'; import { ToastProvider } from '../components/Toast/ToastContext';
import { storeMiddlewareOptions } from '../store/store'; import { storeMiddlewareOptions } from '../store/store';

View file

@ -1,6 +1,8 @@
import type { WebsocketTypes } from '@app/websocket/types';
export const PROTOCOL_VERSION = 14; export const PROTOCOL_VERSION = 14;
export const CLIENT_CONFIG = { export const CLIENT_CONFIG: WebsocketTypes.ClientConfig = {
clientid: 'webatrice', clientid: 'webatrice',
clientver: 'webclient-1.0 (2019-10-31)', clientver: 'webclient-1.0 (2019-10-31)',
clientfeatures: [ clientfeatures: [
@ -9,19 +11,17 @@ export const CLIENT_CONFIG = {
'feature_set', 'feature_set',
'room_chat_history', 'room_chat_history',
'client_warnings', 'client_warnings',
/* unimplemented features */
'forgot_password', 'forgot_password',
'idle_client', 'idle_client',
'mod_log_lookup', 'mod_log_lookup',
'user_ban_history', 'user_ban_history',
// satisfy server reqs for POC
'websocket', 'websocket',
'2.7.0_min_version', '2.7.0_min_version',
'2.8.0_min_version' '2.8.0_min_version',
] ],
} as const; };
export const CLIENT_OPTIONS = { export const CLIENT_OPTIONS: WebsocketTypes.ClientOptions = {
autojoinrooms: true, autojoinrooms: true,
keepalive: 5000 keepalive: 5000,
} as const; };

View file

@ -1,2 +1,2 @@
export { createWebClientResponse } from './response'; export { createWebClientResponse } from './response';
export { createWebClientRequest } from './request'; export { CLIENT_CONFIG, CLIENT_OPTIONS, PROTOCOL_VERSION } from './clientConfig';

View file

@ -1,20 +0,0 @@
import { AdminCommands } from '@app/websocket';
import { WebsocketTypes } from '@app/websocket/types';
export class AdminRequestImpl implements WebsocketTypes.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();
}
}

View file

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

View file

@ -1,142 +0,0 @@
import { GameCommands } from '@app/websocket';
import { WebsocketTypes } from '@app/websocket/types';
import { Data } from '@app/types';
export class GameRequestImpl implements WebsocketTypes.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);
}
}

View file

@ -1,53 +0,0 @@
import { Data } from '@app/types';
import { ModeratorCommands } from '@app/websocket';
import { WebsocketTypes } from '@app/websocket/types';
export class ModeratorRequestImpl implements WebsocketTypes.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);
}
forceActivateUser(usernameToActivate: string, moderatorName: string): void {
ModeratorCommands.forceActivateUser(usernameToActivate, moderatorName);
}
getAdminNotes(userName: string): void {
ModeratorCommands.getAdminNotes(userName);
}
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);
}
grantReplayAccess(replayId: number, moderatorName: string): void {
ModeratorCommands.grantReplayAccess(replayId, moderatorName);
}
updateAdminNotes(userName: string, notes: string): void {
ModeratorCommands.updateAdminNotes(userName, notes);
}
viewLogHistory(filters: Data.ViewLogHistoryParams): void {
ModeratorCommands.viewLogHistory(filters);
}
warnUser(userName: string, reason: string, clientid?: string, removeMessages?: number): void {
ModeratorCommands.warnUser(userName, reason, clientid, removeMessages);
}
}

View file

@ -1,25 +0,0 @@
import { RoomCommands, SessionCommands } from '@app/websocket';
import { WebsocketTypes } from '@app/websocket/types';
import type { App } from '@app/types';
export class RoomsRequestImpl implements WebsocketTypes.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);
}
createGame(roomId: number, params: App.CreateGameParams): void {
RoomCommands.createGame(roomId, params);
}
joinGame(roomId: number, params: App.JoinGameParams): void {
RoomCommands.joinGame(roomId, params);
}
}

View file

@ -1,52 +0,0 @@
import { SessionCommands } from '@app/websocket';
import { WebsocketTypes } from '@app/websocket/types';
export class SessionRequestImpl implements WebsocketTypes.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);
}
}

View file

@ -1,21 +0,0 @@
import { WebsocketTypes } from '@app/websocket/types';
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(): WebsocketTypes.IWebClientRequest {
return {
authentication: new AuthenticationRequestImpl(),
session: new SessionRequestImpl(),
rooms: new RoomsRequestImpl(),
game: new GameRequestImpl(),
admin: new AdminRequestImpl(),
moderator: new ModeratorRequestImpl(),
};
}

View file

@ -60,7 +60,7 @@ export function usePlayer(): PlayerViewModel {
const onRemoveBuddy = () => name && webClient.request.session.removeFromBuddyList(name); const onRemoveBuddy = () => name && webClient.request.session.removeFromBuddyList(name);
const onAddIgnore = () => name && webClient.request.session.addToIgnoreList(name); const onAddIgnore = () => name && webClient.request.session.addToIgnoreList(name);
const onRemoveIgnore = () => name && webClient.request.session.removeFromIgnoreList(name); const onRemoveIgnore = () => name && webClient.request.session.removeFromIgnoreList(name);
const onSendMessage = (message: string) => name && webClient.request.session.sendDirectMessage(name, message); const onSendMessage = (message: string) => name && webClient.request.session.message(name, message);
const onWarnUser = (reason: string) => name && webClient.request.moderator.warnUser(name, reason); const onWarnUser = (reason: string) => name && webClient.request.moderator.warnUser(name, reason);
const onBanFromServer = (minutes: number, reason: string, visibleReason?: string) => const onBanFromServer = (minutes: number, reason: string, visibleReason?: string) =>
name && webClient.request.moderator.banFromServer(minutes, name, undefined, reason, visibleReason); name && webClient.request.moderator.banFromServer(minutes, name, undefined, reason, visibleReason);

View file

@ -31,7 +31,7 @@ const Rooms = ({ rooms, joinedRooms }: RoomsProps) => {
if (joinedRoomIds.has(roomId)) { if (joinedRoomIds.has(roomId)) {
navigate(generatePath(App.RouteEnum.ROOM, { roomId: String(roomId) })); navigate(generatePath(App.RouteEnum.ROOM, { roomId: String(roomId) }));
} else { } else {
webClient.request.rooms.joinRoom(roomId); webClient.request.session.joinRoom(roomId);
} }
}; };

View file

@ -7,8 +7,10 @@ vi.mock('@app/websocket', () => ({
})); }));
vi.mock('@app/api', () => ({ vi.mock('@app/api', () => ({
createWebClientRequest: vi.fn(() => 'request'),
createWebClientResponse: vi.fn(() => 'response'), createWebClientResponse: vi.fn(() => 'response'),
CLIENT_CONFIG: { clientid: 'test', clientver: 'test', clientfeatures: [] },
CLIENT_OPTIONS: { autojoinrooms: false, keepalive: 5000 },
PROTOCOL_VERSION: 14,
})); }));
function Wrapper({ children }: { children: ReactNode }) { function Wrapper({ children }: { children: ReactNode }) {

View file

@ -1,12 +1,22 @@
import { createContext, useContext, useState, ReactNode } from 'react'; import { createContext, useContext, useState, ReactNode } from 'react';
import { WebClient } from '@app/websocket'; import { WebClient } from '@app/websocket';
import { createWebClientRequest, createWebClientResponse } from '@app/api'; import {
CLIENT_CONFIG,
CLIENT_OPTIONS,
PROTOCOL_VERSION,
createWebClientResponse,
} from '@app/api';
export const WebClientContext = createContext<WebClient | null>(null); export const WebClientContext = createContext<WebClient | null>(null);
WebClientContext.displayName = 'WebClientContext'; WebClientContext.displayName = 'WebClientContext';
export function WebClientProvider({ children }: { children: ReactNode }) { export function WebClientProvider({ children }: { children: ReactNode }) {
const [client] = useState(() => new WebClient(createWebClientRequest(), createWebClientResponse())); const [client] = useState(() => new WebClient(
createWebClientResponse(),
CLIENT_CONFIG,
CLIENT_OPTIONS,
PROTOCOL_VERSION,
));
return <WebClientContext value={client}>{children}</WebClientContext>; return <WebClientContext value={client}>{children}</WebClientContext>;
} }

View file

@ -1,312 +0,0 @@
const captured = vi.hoisted(() => ({
wsOptions: null as WebSocketServiceConfig | null,
pbOptions: null as SocketTransport | null,
}));
vi.mock('./services/WebSocketService', () => ({
WebSocketService: vi.fn().mockImplementation(function WebSocketServiceImpl(options: WebSocketServiceConfig) {
captured.wsOptions = options;
return {
message$: { subscribe: vi.fn() },
connect: vi.fn(),
disconnect: vi.fn(),
send: vi.fn(),
checkReadyState: vi.fn().mockReturnValue(true),
};
}),
}));
vi.mock('./services/ProtobufService', () => ({
ProtobufService: vi.fn().mockImplementation(function ProtobufServiceImpl(transport: SocketTransport) {
captured.pbOptions = transport;
return {
handleMessageEvent: vi.fn(),
resetCommands: vi.fn(),
};
}),
}));
import { WebClient } from './WebClient';
import { WebSocketService } from './services/WebSocketService';
import { ProtobufService } from './services/ProtobufService';
import { StatusEnum } from './types/StatusEnum';
import { Subject } from 'rxjs';
import { Mock } from 'vitest';
import { SocketTransport } from './services/ProtobufService';
import { WebSocketServiceConfig } from './services/WebSocketService';
import type { IWebClientResponse } from './types/WebClientResponse';
import type { IWebClientRequest } from './types/WebClientRequest';
import type { ConnectTarget } from './types/WebClientConfig';
import { installMockWebSocket } from './__mocks__/helpers';
import { create, setExtension, toBinary } from '@bufbuild/protobuf';
import {
Event_ServerIdentification_ext,
Event_ServerIdentification_ServerOptions,
Event_ServerIdentificationSchema,
ServerMessageSchema,
ServerMessage_MessageType,
SessionEventSchema,
} from '@app/generated';
import { PROTOCOL_VERSION } from './config';
function buildServerIdentificationMessage({
protocolVersion = PROTOCOL_VERSION,
serverOptions = 0,
}: { protocolVersion?: number; serverOptions?: number } = {}): Uint8Array {
const ident = create(Event_ServerIdentificationSchema, {
serverName: 'TestServer',
serverVersion: '2.8.0',
protocolVersion,
serverOptions,
});
const sessionEvent = create(SessionEventSchema);
setExtension(sessionEvent, Event_ServerIdentification_ext, ident);
const server = create(ServerMessageSchema, {
messageType: ServerMessage_MessageType.SESSION_EVENT,
sessionEvent,
});
return toBinary(ServerMessageSchema, server);
}
function makeMockResponse(): IWebClientResponse {
return {
session: {
initialized: vi.fn(),
connectionAttempted: vi.fn(),
connectionFailed: vi.fn(),
clearStore: vi.fn(),
updateStatus: vi.fn(),
testConnectionSuccessful: vi.fn(),
testConnectionFailed: vi.fn(),
},
room: { clearStore: vi.fn() },
game: { clearStore: vi.fn() },
admin: {},
moderator: {},
} as unknown as IWebClientResponse;
}
function makeMockRequest(): IWebClientRequest {
return {} as IWebClientRequest;
}
describe('WebClient', () => {
let client: WebClient;
let mockResponse: IWebClientResponse;
let mockRequest: IWebClientRequest;
let messageSubject: Subject<MessageEvent>;
beforeEach(() => {
(WebClient as unknown as { _instance: WebClient | null })._instance = null;
(ProtobufService as Mock).mockImplementation(function ProtobufServiceImpl(transport: SocketTransport) {
captured.pbOptions = transport;
return {
handleMessageEvent: vi.fn(),
resetCommands: vi.fn(),
};
});
messageSubject = new Subject<MessageEvent>();
(WebSocketService as Mock).mockImplementation(function WebSocketServiceImpl(options: WebSocketServiceConfig) {
captured.wsOptions = options;
return {
message$: messageSubject,
connect: vi.fn(),
disconnect: vi.fn(),
send: vi.fn(),
checkReadyState: vi.fn().mockReturnValue(true),
};
});
vi.spyOn(console, 'log').mockImplementation(() => {});
mockResponse = makeMockResponse();
mockRequest = makeMockRequest();
client = new WebClient(mockRequest, mockResponse);
});
afterEach(() => {
vi.restoreAllMocks();
(WebClient as unknown as { _instance: WebClient | null })._instance = null;
});
describe('constructor', () => {
it('stores the request and response on the instance', () => {
expect(client.request).toBe(mockRequest);
expect(client.response).toBe(mockResponse);
});
it('subscribes socket.message$ to protobuf.handleMessageEvent', () => {
const event = { data: new ArrayBuffer(0) } as MessageEvent;
messageSubject.next(event);
expect(client.protobuf.handleMessageEvent).toHaveBeenCalledWith(event);
});
it('calls response.session.initialized', () => {
expect(mockResponse.session.initialized).toHaveBeenCalled();
});
it('sets WebClient.instance to the constructed instance', () => {
expect(WebClient.instance).toBe(client);
});
it('throws when instantiated more than once', () => {
expect(() => new WebClient(makeMockRequest(), makeMockResponse())).toThrow(/singleton/);
});
});
describe('static instance accessor', () => {
it('throws when accessed before construction', () => {
(WebClient as unknown as { _instance: WebClient | null })._instance = null;
expect(() => WebClient.instance).toThrow(/not been initialized/);
});
});
describe('connect', () => {
it('calls response.session.connectionAttempted', () => {
const target: ConnectTarget = { host: 'h', port: '1' };
client.connect(target);
expect(mockResponse.session.connectionAttempted).toHaveBeenCalled();
});
it('calls socket.connect with target', () => {
const target: ConnectTarget = { host: 'h', port: '1' };
client.connect(target);
expect(client.socket.connect).toHaveBeenCalledWith(target);
});
});
describe('testConnect', () => {
let MockWS: ReturnType<typeof installMockWebSocket>['MockWS'];
let wsMockInstance: ReturnType<typeof installMockWebSocket>['mockInstance'];
let restoreWS: ReturnType<typeof installMockWebSocket>['restore'];
beforeEach(() => {
vi.useFakeTimers();
const installed = installMockWebSocket();
MockWS = installed.MockWS;
wsMockInstance = installed.mockInstance;
restoreWS = installed.restore;
});
afterEach(() => {
restoreWS();
vi.useRealTimers();
});
const target: ConnectTarget = { host: 'h', port: '1' };
it('creates a WebSocket with the correct URL', () => {
client.testConnect(target);
expect(MockWS).toHaveBeenCalledWith(expect.stringContaining('://h:1'));
});
it('routes path-bearing hosts through the default TLS port (nginx proxy)', () => {
client.testConnect({ host: 'server.example.com/servatrice', port: '4748' });
expect(MockWS).toHaveBeenCalledWith(expect.stringMatching(/:\/\/server\.example\.com\/servatrice$/));
});
it('dispatches testConnectionSuccessful with supportsHashedPassword=true when the bit is set', () => {
client.testConnect(target);
const data = buildServerIdentificationMessage({
serverOptions: Event_ServerIdentification_ServerOptions.SupportsPasswordHash,
});
wsMockInstance.onmessage({ data: data.buffer });
expect(mockResponse.session.testConnectionSuccessful).toHaveBeenCalledWith(true);
expect(wsMockInstance.close).toHaveBeenCalled();
});
it('dispatches testConnectionSuccessful with supportsHashedPassword=false for naked-password servers', () => {
client.testConnect(target);
const data = buildServerIdentificationMessage({ serverOptions: 0 });
wsMockInstance.onmessage({ data: data.buffer });
expect(mockResponse.session.testConnectionSuccessful).toHaveBeenCalledWith(false);
});
it('fails on protocol-version mismatch instead of reporting success', () => {
client.testConnect(target);
const data = buildServerIdentificationMessage({ protocolVersion: PROTOCOL_VERSION + 1 });
wsMockInstance.onmessage({ data: data.buffer });
expect(mockResponse.session.testConnectionFailed).toHaveBeenCalled();
expect(mockResponse.session.testConnectionSuccessful).not.toHaveBeenCalled();
});
it('calls testConnectionFailed on error', () => {
client.testConnect(target);
wsMockInstance.onerror();
expect(mockResponse.session.testConnectionFailed).toHaveBeenCalled();
});
it('fires testConnectionFailed when ServerIdentification never arrives before the keepalive timeout', () => {
client.testConnect(target);
vi.advanceTimersByTime(5000);
expect(wsMockInstance.close).toHaveBeenCalled();
expect(mockResponse.session.testConnectionFailed).toHaveBeenCalled();
});
it('closes the prior in-flight socket on rapid re-click', () => {
const { instances } = installMockWebSocket();
// The fresh installMockWebSocket replaces the stub from beforeEach so
// we observe the next two constructions in isolation.
client.testConnect(target);
const first = instances[instances.length - 1];
expect(first.close).not.toHaveBeenCalled();
client.testConnect(target);
expect(first.close).toHaveBeenCalled();
});
});
describe('disconnect', () => {
it('delegates to socket.disconnect', () => {
client.disconnect();
expect(client.socket.disconnect).toHaveBeenCalled();
});
});
describe('updateStatus', () => {
it('sets the status', () => {
client.updateStatus(StatusEnum.CONNECTED);
expect(client.status).toBe(StatusEnum.CONNECTED);
});
it('calls protobuf.resetCommands on DISCONNECTED', () => {
client.updateStatus(StatusEnum.DISCONNECTED);
expect(client.protobuf.resetCommands).toHaveBeenCalled();
});
it('does not reset protobuf when status is not DISCONNECTED', () => {
client.updateStatus(StatusEnum.CONNECTED);
expect(client.protobuf.resetCommands).not.toHaveBeenCalled();
});
});
describe('constructor closures', () => {
it('keepAliveFn is set to ping function in WebSocketService', () => {
expect(captured.wsOptions!.keepAliveFn).toBeDefined();
expect(typeof captured.wsOptions!.keepAliveFn).toBe('function');
});
it('onStatusChange routes to response.session.updateStatus and updates own status', () => {
captured.wsOptions!.onStatusChange(StatusEnum.CONNECTED, 'Connected');
expect(mockResponse.session.updateStatus).toHaveBeenCalledWith(StatusEnum.CONNECTED, 'Connected');
expect(client.status).toBe(StatusEnum.CONNECTED);
});
it('onConnectionFailed routes to response.session.connectionFailed', () => {
captured.wsOptions!.onConnectionFailed();
expect(mockResponse.session.connectionFailed).toHaveBeenCalled();
});
it('send closure delegates to socket.send', () => {
const data = new Uint8Array([1, 2, 3]);
captured.pbOptions!.send(data);
expect(client.socket.send).toHaveBeenCalledWith(data);
});
it('isOpen closure delegates to socket.checkReadyState', () => {
const result = captured.pbOptions!.isOpen();
expect(client.socket.checkReadyState).toHaveBeenCalledWith(WebSocket.OPEN);
expect(result).toBe(true);
});
});
});

View file

@ -1,166 +0,0 @@
import { fromBinary, getExtension, hasExtension } from '@bufbuild/protobuf';
import {
Event_ServerIdentification_ext,
ServerMessageSchema,
ServerMessage_MessageType,
} from '@app/generated';
import { ping } from './commands/session';
import { CLIENT_OPTIONS, PROTOCOL_VERSION } from './config';
import { GameEvents } from './events/game';
import { RoomEvents } from './events/room';
import { SessionEvents } from './events/session';
import type { ConnectTarget } from './types/WebClientConfig';
import type { IWebClientRequest } from './types/WebClientRequest';
import type { IWebClientResponse } from './types/WebClientResponse';
import { StatusEnum } from './types/StatusEnum';
import { ProtobufService } from './services/ProtobufService';
import { WebSocketService } from './services/WebSocketService';
import { buildWebSocketUrl } from './utils/buildWebSocketUrl';
import { passwordSaltSupported } from './utils/passwordHasher';
export class WebClient {
private static _instance: WebClient | null = null;
static get instance(): WebClient {
if (!WebClient._instance) {
throw new Error(
'WebClient has not been initialized. Instantiate it via `new WebClient()` before accessing `WebClient.instance`.'
);
}
return WebClient._instance;
}
protobuf: ProtobufService;
socket: WebSocketService;
status: StatusEnum;
private testSocket: WebSocket | null = null;
constructor(
public request: IWebClientRequest,
public response: IWebClientResponse
) {
if (WebClient._instance) {
throw new Error('WebClient is a singleton and has already been initialized.');
}
this.socket = new WebSocketService({
keepAliveFn: ping,
onStatusChange: (status, description) => {
this.response.session.updateStatus(status, description);
this.updateStatus(status);
},
onConnectionFailed: () => {
this.response.session.connectionFailed();
},
reconnect: {
maxAttempts: 5,
baseDelayMs: 1000,
maxDelayMs: 30000,
},
});
this.protobuf = new ProtobufService(
{
send: (data) => this.socket.send(data),
isOpen: () => this.socket.checkReadyState(WebSocket.OPEN),
},
{ game: GameEvents, room: RoomEvents, session: SessionEvents },
);
this.socket.message$.subscribe((message: MessageEvent) => {
this.protobuf.handleMessageEvent(message);
});
WebClient._instance = this;
this.response.session.initialized();
}
public connect(target: ConnectTarget): void {
this.response.session.connectionAttempted();
this.socket.connect(target);
}
public testConnect(target: ConnectTarget): void {
// A prior test connection still in flight when the user re-clicks would
// otherwise leak the socket until its keepalive timeout. Close eagerly.
if (this.testSocket) {
this.testSocket.close();
this.testSocket = null;
}
const protocol = window.location.hostname === 'localhost' ? 'ws' : 'wss';
const socket = new WebSocket(buildWebSocketUrl(protocol, target.host, target.port));
socket.binaryType = 'arraybuffer';
this.testSocket = socket;
// "Green" means reachable AND speaking a compatible Cockatrice protocol.
// Waiting for Event_ServerIdentification lets us read the hashed-password
// capability before the user ever logs in. The bitmask is resolved here
// (the websocket layer owns protocol details) so downstream consumers
// receive a domain-level boolean instead of a raw integer.
let resolved = false;
const resolve = (ok: boolean, supportsHashedPassword = false): void => {
if (resolved) {
return;
}
resolved = true;
clearTimeout(timeout);
// Suppress dispatches from a superseded socket — a newer test has
// already taken over and we'd race a stale result into its pending-ref.
if (this.testSocket === socket) {
if (ok) {
this.response.session.testConnectionSuccessful(supportsHashedPassword);
} else {
this.response.session.testConnectionFailed();
}
this.testSocket = null;
}
socket.close();
};
const timeout = setTimeout(() => resolve(false), CLIENT_OPTIONS.keepalive);
socket.onmessage = (event: MessageEvent) => {
try {
const msg = fromBinary(ServerMessageSchema, new Uint8Array(event.data));
if (msg.messageType !== ServerMessage_MessageType.SESSION_EVENT) {
return;
}
const sessionEvent = msg.sessionEvent;
if (!sessionEvent || !hasExtension(sessionEvent, Event_ServerIdentification_ext)) {
return;
}
const ident = getExtension(sessionEvent, Event_ServerIdentification_ext);
if (ident.protocolVersion !== PROTOCOL_VERSION) {
resolve(false);
return;
}
resolve(true, passwordSaltSupported(ident.serverOptions));
} catch {
resolve(false);
}
};
socket.onerror = () => resolve(false);
socket.onclose = () => resolve(false);
}
public disconnect(): void {
this.socket.disconnect();
}
public updateStatus(status: StatusEnum): void {
this.status = status;
if (status === StatusEnum.DISCONNECTED) {
this.protobuf.resetCommands();
}
}
public get isReconnecting(): boolean {
return this.status === StatusEnum.RECONNECTING;
}
}

View file

@ -1,169 +0,0 @@
/**
* Shared WebClient mock the single source of truth for all websocket
* layer unit tests.
*
* Vitest resolves this file whenever a spec calls `vi.mock('...WebClient')`
* without providing a factory. Each spec file gets its own module graph
* (isolate: true), so there are no factory-conflict issues.
*
* Usage in spec files:
*
* vi.mock('../../WebClient');
* import { WebClient } from '../../WebClient';
* // WebClient.instance.response.game.cardMoved ← vi.fn()
* // WebClient.instance.protobuf.sendGameCommand ← vi.fn()
*
* `useWebClientCleanup()` is NOT required `instance` is a plain
* property, not a getter that throws.
*/
const session = {
initialized: vi.fn(),
connectionAttempted: vi.fn(),
clearStore: vi.fn(),
loginSuccessful: vi.fn(),
loginFailed: vi.fn(),
connectionFailed: vi.fn(),
testConnectionSuccessful: vi.fn(),
testConnectionFailed: vi.fn(),
updateBuddyList: vi.fn(),
addToBuddyList: vi.fn(),
removeFromBuddyList: vi.fn(),
updateIgnoreList: vi.fn(),
addToIgnoreList: vi.fn(),
removeFromIgnoreList: vi.fn(),
updateInfo: vi.fn(),
updateStatus: vi.fn(),
updateUser: vi.fn(),
updateUsers: vi.fn(),
userJoined: vi.fn(),
userLeft: vi.fn(),
serverMessage: vi.fn(),
accountAwaitingActivation: vi.fn(),
accountActivationSuccess: vi.fn(),
accountActivationFailed: vi.fn(),
registrationRequiresEmail: vi.fn(),
registrationSuccess: vi.fn(),
registrationFailed: vi.fn(),
registrationEmailError: vi.fn(),
registrationPasswordError: vi.fn(),
registrationUserNameError: vi.fn(),
resetPasswordChallenge: vi.fn(),
resetPassword: vi.fn(),
resetPasswordSuccess: vi.fn(),
resetPasswordFailed: vi.fn(),
accountPasswordChange: vi.fn(),
accountEditChanged: vi.fn(),
accountImageChanged: vi.fn(),
getUserInfo: vi.fn(),
getGamesOfUser: vi.fn(),
gameJoined: vi.fn(),
notifyUser: vi.fn(),
playerPropertiesChanged: vi.fn(),
serverShutdown: vi.fn(),
userMessage: vi.fn(),
addToList: vi.fn(),
removeFromList: vi.fn(),
deleteServerDeck: vi.fn(),
updateServerDecks: vi.fn(),
uploadServerDeck: vi.fn(),
downloadServerDeck: vi.fn(),
createServerDeckDir: vi.fn(),
deleteServerDeckDir: vi.fn(),
replayList: vi.fn(),
replayAdded: vi.fn(),
replayModifyMatch: vi.fn(),
replayDeleteMatch: vi.fn(),
replayDownloaded: vi.fn(),
};
const room = {
clearStore: vi.fn(),
joinRoom: vi.fn(),
leaveRoom: vi.fn(),
updateRooms: vi.fn(),
updateGames: vi.fn(),
addMessage: vi.fn(),
userJoined: vi.fn(),
userLeft: vi.fn(),
removeMessages: vi.fn(),
gameCreated: vi.fn(),
joinedGame: vi.fn(),
setJoinGamePending: vi.fn(),
setJoinGameError: vi.fn(),
};
const game = {
clearStore: vi.fn(),
gameStateChanged: vi.fn(),
playerJoined: vi.fn(),
playerLeft: vi.fn(),
playerPropertiesChanged: vi.fn(),
gameClosed: vi.fn(),
gameHostChanged: vi.fn(),
kicked: vi.fn(),
gameSay: vi.fn(),
cardMoved: vi.fn(),
cardFlipped: vi.fn(),
cardDestroyed: vi.fn(),
cardAttached: vi.fn(),
tokenCreated: vi.fn(),
cardAttrChanged: vi.fn(),
cardCounterChanged: vi.fn(),
arrowCreated: vi.fn(),
arrowDeleted: vi.fn(),
counterCreated: vi.fn(),
counterSet: vi.fn(),
counterDeleted: vi.fn(),
cardsDrawn: vi.fn(),
cardsRevealed: vi.fn(),
zoneShuffled: vi.fn(),
dieRolled: vi.fn(),
activePlayerSet: vi.fn(),
activePhaseSet: vi.fn(),
turnReversed: vi.fn(),
zoneDumped: vi.fn(),
zonePropertiesChanged: vi.fn(),
};
const admin = {
adjustMod: vi.fn(),
reloadConfig: vi.fn(),
shutdownServer: vi.fn(),
updateServerMessage: vi.fn(),
};
const moderator = {
banFromServer: vi.fn(),
banHistory: vi.fn(),
viewLogs: vi.fn(),
warnHistory: vi.fn(),
warnListOptions: vi.fn(),
warnUser: vi.fn(),
grantReplayAccess: vi.fn(),
forceActivateUser: vi.fn(),
getAdminNotes: vi.fn(),
updateAdminNotes: vi.fn(),
};
export const WebClient = {
_instance: null as any,
instance: {
connect: vi.fn(),
testConnect: vi.fn(),
disconnect: vi.fn(),
updateStatus: vi.fn(),
status: 0 as number,
config: {},
protobuf: {
sendSessionCommand: vi.fn(),
sendRoomCommand: vi.fn(),
sendGameCommand: vi.fn(),
sendAdminCommand: vi.fn(),
sendModeratorCommand: vi.fn(),
resetCommands: vi.fn(),
},
response: { session, room, game, admin, moderator },
},
};

View file

@ -1,41 +0,0 @@
/**
* Factory for invoking BackendService command callbacks in unit tests.
*
* @param mockFn - The vi.Mock for the BackendService send method
* (e.g. BackendService.sendSessionCommand as vi.Mock).
* @param optsArgIndex - Index of the options argument in the mock call.
* Defaults to 2 (ext, value, options).
* Use 3 for sendRoomCommand (roomId, ext, value, options).
*/
import { Mock } from 'vitest';
export function makeCallbackHelpers(mockFn: Mock, optsArgIndex = 2) {
function getLastSendOpts() {
const calls = mockFn.mock.calls;
return calls[calls.length - 1]?.[optsArgIndex];
}
function invokeOnSuccess(response: any = {}, raw?: any) {
getLastSendOpts()?.onSuccess?.(response, raw ?? response);
}
function invokeResponseCode(code: number, raw: any = { responseCode: code }) {
const opts = getLastSendOpts();
if (opts?.onResponseCode?.[code]) {
opts.onResponseCode[code](raw);
}
}
function invokeOnError(code: number = 99, raw: any = {}) {
getLastSendOpts()?.onError?.(code, raw);
}
function invokeCallback(callbackName: string, ...args: any[]) {
const opts = getLastSendOpts();
if (opts?.[callbackName]) {
opts[callbackName](...args);
}
}
return { getLastSendOpts, invokeOnSuccess, invokeResponseCode, invokeOnError, invokeCallback };
}

View file

@ -1,40 +0,0 @@
/**
* Shared mock factories for websocket layer unit tests.
*/
/** Builds a mock WebSocket instance */
export function makeMockWebSocketInstance() {
return {
send: vi.fn(),
close: vi.fn(),
readyState: WebSocket.OPEN as number,
binaryType: '' as BinaryType,
onopen: null as any,
onclose: null as any,
onerror: null as any,
onmessage: null as any,
};
}
/** Installs a mock WebSocket constructor on global. Returns the mock instance and a cleanup function. */
export function installMockWebSocket() {
const originalWebSocket = (globalThis as any).WebSocket;
const mockInstance = makeMockWebSocketInstance();
const instances: ReturnType<typeof makeMockWebSocketInstance>[] = [mockInstance];
let firstCall = true;
const MockWS = vi.fn(function MockWebSocket() {
if (firstCall) {
firstCall = false;
return mockInstance;
}
const next = makeMockWebSocketInstance();
instances.push(next);
return next;
}) as any;
MockWS.OPEN = 1;
MockWS.CLOSED = 3;
(globalThis as any).WebSocket = MockWS;
return { MockWS, mockInstance, instances, restore: () => {
(globalThis as any).WebSocket = originalWebSocket;
} };
}

View file

@ -1,111 +0,0 @@
/**
* Shared mock shape factories for session command specs.
*
* Usage inside vi.mock() factory callbacks:
*
* vi.mock('../../WebClient', async () => {
* const { makeWebClientMock } = await import('../../__mocks__/sessionCommandMocks');
* return { WebClient: { instance: makeWebClientMock() } };
* });
*/
/** Superset WebClient instance mock — covers all properties used across both session spec files. */
export function makeWebClientMock() {
return {
connect: vi.fn(),
testConnect: vi.fn(),
disconnect: vi.fn(),
updateStatus: vi.fn(),
config: {},
status: 0,
protobuf: {
sendSessionCommand: vi.fn(),
sendRoomCommand: vi.fn(),
sendGameCommand: vi.fn(),
sendAdminCommand: vi.fn(),
sendModeratorCommand: vi.fn(),
},
response: {
session: makeSessionPersistenceMock(),
room: { joinRoom: vi.fn() },
game: {},
admin: {},
moderator: {},
},
};
}
/** Utils mock with unified return values. */
export function makeUtilsMock() {
return {
hashPassword: vi.fn().mockReturnValue('hashed_pw'),
generateSalt: vi.fn().mockReturnValue('randSalt'),
passwordSaltSupported: vi.fn().mockReturnValue(0),
};
}
/** Superset SessionPersistence mock — covers all methods used across both session spec files. */
export function makeSessionPersistenceMock() {
return {
loginSuccessful: vi.fn(),
loginFailed: vi.fn(),
updateBuddyList: vi.fn(),
updateIgnoreList: vi.fn(),
updateUser: vi.fn(),
updateUsers: vi.fn(),
accountAwaitingActivation: vi.fn(),
accountActivationSuccess: vi.fn(),
accountActivationFailed: vi.fn(),
updateStatus: vi.fn(),
addToList: vi.fn(),
removeFromList: vi.fn(),
deleteServerDeck: vi.fn(),
deleteServerDeckDir: vi.fn(),
updateServerDecks: vi.fn(),
uploadServerDeck: vi.fn(),
createServerDeckDir: vi.fn(),
getGamesOfUser: vi.fn(),
getUserInfo: vi.fn(),
accountPasswordChange: vi.fn(),
accountEditChanged: vi.fn(),
accountImageChanged: vi.fn(),
replayList: vi.fn(),
replayAdded: vi.fn(),
replayModifyMatch: vi.fn(),
replayDeleteMatch: vi.fn(),
downloadServerDeck: vi.fn(),
replayDownloaded: vi.fn(),
resetPasswordChallenge: vi.fn(),
resetPassword: vi.fn(),
resetPasswordFailed: vi.fn(),
resetPasswordSuccess: vi.fn(),
registrationFailed: vi.fn(),
registrationSuccess: vi.fn(),
registrationUserNameError: vi.fn(),
registrationPasswordError: vi.fn(),
registrationEmailError: vi.fn(),
registrationRequiresEmail: vi.fn(),
};
}
/**
* Session barrel mock pure vi.fn() map for all cross-command calls.
* Used as-is by sessionCommands-complex.spec.ts, or spread over jest.requireActual
* by sessionCommands-simple.spec.ts to preserve real implementations for
* the commands under test.
*/
export function makeSessionBarrelMock() {
return {
login: vi.fn(),
register: vi.fn(),
activate: vi.fn(),
forgotPasswordReset: vi.fn(),
forgotPasswordRequest: vi.fn(),
forgotPasswordChallenge: vi.fn(),
requestPasswordSalt: vi.fn(),
listUsers: vi.fn(),
listRooms: vi.fn(),
updateStatus: vi.fn(),
disconnect: vi.fn(),
};
}

View file

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

View file

@ -1,93 +0,0 @@
vi.mock('../../WebClient');
import { makeCallbackHelpers } from '../../__mocks__/callbackHelpers';
import { WebClient } from '../../WebClient';
import { adjustMod } from './adjustMod';
import { reloadConfig } from './reloadConfig';
import { shutdownServer } from './shutdownServer';
import { updateServerMessage } from './updateServerMessage';
import {
Command_AdjustMod_ext,
Command_ReloadConfig_ext,
Command_ShutdownServer_ext,
Command_UpdateServerMessage_ext,
} from '@app/generated';
import { Mock } from 'vitest';
const { invokeOnSuccess } = makeCallbackHelpers(
WebClient.instance.protobuf.sendAdminCommand as Mock,
2
);
describe('adjustMod', () => {
it('calls sendAdminCommand with Command_AdjustMod extension and fields', () => {
adjustMod('alice', true, false);
expect(WebClient.instance.protobuf.sendAdminCommand).toHaveBeenCalledWith(
Command_AdjustMod_ext,
expect.objectContaining({ userName: 'alice', shouldBeMod: true, shouldBeJudge: false }),
expect.any(Object)
);
});
it('onSuccess calls response.admin.adjustMod', () => {
adjustMod('alice', true, false);
invokeOnSuccess();
expect(WebClient.instance.response.admin.adjustMod).toHaveBeenCalledWith('alice', true, false);
});
});
describe('reloadConfig', () => {
it('calls sendAdminCommand with Command_ReloadConfig extension', () => {
reloadConfig();
expect(WebClient.instance.protobuf.sendAdminCommand).toHaveBeenCalledWith(
Command_ReloadConfig_ext,
expect.any(Object),
expect.any(Object)
);
});
it('onSuccess calls response.admin.reloadConfig', () => {
reloadConfig();
invokeOnSuccess();
expect(WebClient.instance.response.admin.reloadConfig).toHaveBeenCalled();
});
});
describe('shutdownServer', () => {
it('calls sendAdminCommand with Command_ShutdownServer extension and fields', () => {
shutdownServer('maintenance', 10);
expect(WebClient.instance.protobuf.sendAdminCommand).toHaveBeenCalledWith(
Command_ShutdownServer_ext,
expect.objectContaining({ reason: 'maintenance', minutes: 10 }),
expect.any(Object)
);
});
it('onSuccess calls response.admin.shutdownServer', () => {
shutdownServer('maintenance', 10);
invokeOnSuccess();
expect(WebClient.instance.response.admin.shutdownServer).toHaveBeenCalled();
});
});
describe('updateServerMessage', () => {
it('calls sendAdminCommand with Command_UpdateServerMessage extension', () => {
updateServerMessage();
expect(WebClient.instance.protobuf.sendAdminCommand).toHaveBeenCalledWith(
Command_UpdateServerMessage_ext,
expect.any(Object),
expect.any(Object)
);
});
it('onSuccess calls response.admin.updateServerMessage', () => {
updateServerMessage();
invokeOnSuccess();
expect(WebClient.instance.response.admin.updateServerMessage).toHaveBeenCalled();
});
});

View file

@ -1,4 +0,0 @@
export * from './adjustMod';
export * from './reloadConfig';
export * from './shutdownServer';
export * from './updateServerMessage';

View file

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

View file

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

View file

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

View file

@ -1,73 +0,0 @@
// Byte-level verification that Command_AttachCard's proto2 optional target
// fields stay UNSET when we build an "Unattach" — i.e. the client omits
// them entirely rather than sending sentinel values. Desktop's server-side
// `has_target_player_id()` / `has_target_zone()` / `has_target_card_id()`
// drives the unattach branch; if any target field gets serialized, the
// server treats it as a re-attach instead of an unattach.
//
// See the M6 Unattach deferrable in webclient/plans/gameboard-deferrables.md.
import { create, isFieldSet, toBinary, fromBinary } from '@bufbuild/protobuf';
import { Command_AttachCardSchema } from '@app/generated';
describe('Command_AttachCard proto2 presence (Unattach invariant)', () => {
it('omitting target fields leaves them unset on the in-memory message', () => {
const msg = create(Command_AttachCardSchema, {
startZone: 'table',
cardId: 10,
});
expect(isFieldSet(msg, Command_AttachCardSchema.field.startZone)).toBe(true);
expect(isFieldSet(msg, Command_AttachCardSchema.field.cardId)).toBe(true);
expect(isFieldSet(msg, Command_AttachCardSchema.field.targetPlayerId)).toBe(false);
expect(isFieldSet(msg, Command_AttachCardSchema.field.targetZone)).toBe(false);
expect(isFieldSet(msg, Command_AttachCardSchema.field.targetCardId)).toBe(false);
});
it('omitting target fields omits them from the serialized bytes', () => {
const msg = create(Command_AttachCardSchema, {
startZone: 'table',
cardId: 10,
});
const bytes = toBinary(Command_AttachCardSchema, msg);
// Round-trip to confirm the wire format also reports the fields unset.
const parsed = fromBinary(Command_AttachCardSchema, bytes);
expect(isFieldSet(parsed, Command_AttachCardSchema.field.targetPlayerId)).toBe(false);
expect(isFieldSet(parsed, Command_AttachCardSchema.field.targetZone)).toBe(false);
expect(isFieldSet(parsed, Command_AttachCardSchema.field.targetCardId)).toBe(false);
});
it('passing an explicit targetPlayerId DOES set presence (so "attach" paths still work)', () => {
const msg = create(Command_AttachCardSchema, {
startZone: 'table',
cardId: 10,
targetPlayerId: 5,
targetZone: 'table',
targetCardId: 99,
});
const bytes = toBinary(Command_AttachCardSchema, msg);
const parsed = fromBinary(Command_AttachCardSchema, bytes);
expect(isFieldSet(parsed, Command_AttachCardSchema.field.targetPlayerId)).toBe(true);
expect(isFieldSet(parsed, Command_AttachCardSchema.field.targetZone)).toBe(true);
expect(isFieldSet(parsed, Command_AttachCardSchema.field.targetCardId)).toBe(true);
expect(parsed.targetPlayerId).toBe(5);
});
it('setting a target field to its default -1 still records presence (presence != default)', () => {
// This is the trap the deferrable warned about: proto2 `optional` with a
// default of -1 treats an explicit `-1` as "field set with value -1",
// which the server interprets as "attach to slot -1" rather than unattach.
// Clients must OMIT the field entirely to unattach.
const msg = create(Command_AttachCardSchema, {
startZone: 'table',
cardId: 10,
targetPlayerId: -1,
});
expect(isFieldSet(msg, Command_AttachCardSchema.field.targetPlayerId)).toBe(true);
});
});

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,34 +0,0 @@
export { leaveGame } from './leaveGame';
export { kickFromGame } from './kickFromGame';
export { gameSay } from './gameSay';
export { readyStart } from './readyStart';
export { concede } from './concede';
export { unconcede } from './unconcede';
export { judge } from './judge';
export { nextTurn } from './nextTurn';
export { setActivePhase } from './setActivePhase';
export { reverseTurn } from './reverseTurn';
export { moveCard } from './moveCard';
export { flipCard } from './flipCard';
export { attachCard } from './attachCard';
export { createToken } from './createToken';
export { setCardAttr } from './setCardAttr';
export { setCardCounter } from './setCardCounter';
export { incCardCounter } from './incCardCounter';
export { drawCards } from './drawCards';
export { undoDraw } from './undoDraw';
export { createArrow } from './createArrow';
export { deleteArrow } from './deleteArrow';
export { createCounter } from './createCounter';
export { setCounter } from './setCounter';
export { incCounter } from './incCounter';
export { delCounter } from './delCounter';
export { shuffle } from './shuffle';
export { dumpZone } from './dumpZone';
export { revealCards } from './revealCards';
export { changeZoneProperties } from './changeZoneProperties';
export { deckSelect } from './deckSelect';
export { setSideboardPlan } from './setSideboardPlan';
export { setSideboardLock } from './setSideboardLock';
export { mulligan } from './mulligan';
export { rollDie } from './rollDie';

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,5 +0,0 @@
export * as AdminCommands from './admin';
export * as GameCommands from './game';
export * as ModeratorCommands from './moderator';
export * as RoomCommands from './room';
export * as SessionCommands from './session';

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,10 +0,0 @@
export * from './banFromServer';
export * from './forceActivateUser';
export * from './getAdminNotes';
export * from './getBanHistory';
export * from './getWarnHistory';
export * from './getWarnList';
export * from './grantReplayAccess';
export * from './updateAdminNotes';
export * from './viewLogHistory';
export * from './warnUser';

View file

@ -1,219 +0,0 @@
vi.mock('../../WebClient');
import { makeCallbackHelpers } from '../../__mocks__/callbackHelpers';
import { WebClient } from '../../WebClient';
import {
Command_BanFromServer_ext,
Command_ForceActivateUser_ext,
Command_GetAdminNotes_ext,
Command_GetBanHistory_ext,
Command_GetWarnHistory_ext,
Command_GetWarnList_ext,
Command_GrantReplayAccess_ext,
Command_UpdateAdminNotes_ext,
Command_ViewLogHistory_ext,
Command_ViewLogHistorySchema,
Command_WarnUser_ext,
Response_BanHistory_ext,
Response_GetAdminNotes_ext,
Response_ViewLogHistory_ext,
Response_WarnHistory_ext,
Response_WarnList_ext,
} from '@app/generated';
import { banFromServer } from './banFromServer';
import { forceActivateUser } from './forceActivateUser';
import { getAdminNotes } from './getAdminNotes';
import { getBanHistory } from './getBanHistory';
import { getWarnHistory } from './getWarnHistory';
import { getWarnList } from './getWarnList';
import { grantReplayAccess } from './grantReplayAccess';
import { updateAdminNotes } from './updateAdminNotes';
import { viewLogHistory } from './viewLogHistory';
import { warnUser } from './warnUser';
import { create } from '@bufbuild/protobuf';
import { Mock } from 'vitest';
const { invokeOnSuccess } = makeCallbackHelpers(
WebClient.instance.protobuf.sendModeratorCommand as Mock,
2
);
describe('banFromServer', () => {
it('calls sendModeratorCommand with Command_BanFromServer', () => {
banFromServer(30, 'alice', '1.2.3.4', 'reason', 'visible', 'cid', 1);
expect(WebClient.instance.protobuf.sendModeratorCommand).toHaveBeenCalledWith(
Command_BanFromServer_ext,
expect.objectContaining({ minutes: 30, userName: 'alice' }),
expect.any(Object)
);
});
it('onSuccess calls response.moderator.banFromServer', () => {
banFromServer(30, 'alice');
invokeOnSuccess();
expect(WebClient.instance.response.moderator.banFromServer).toHaveBeenCalledWith('alice');
});
});
describe('forceActivateUser', () => {
it('calls sendModeratorCommand with Command_ForceActivateUser', () => {
forceActivateUser('alice', 'mod1');
expect(WebClient.instance.protobuf.sendModeratorCommand).toHaveBeenCalledWith(
Command_ForceActivateUser_ext, expect.any(Object), expect.any(Object)
);
});
it('onSuccess calls response.moderator.forceActivateUser', () => {
forceActivateUser('alice', 'mod1');
invokeOnSuccess();
expect(WebClient.instance.response.moderator.forceActivateUser).toHaveBeenCalledWith('alice', 'mod1');
});
});
describe('getAdminNotes', () => {
it('calls sendModeratorCommand with Command_GetAdminNotes', () => {
getAdminNotes('alice');
expect(WebClient.instance.protobuf.sendModeratorCommand).toHaveBeenCalledWith(
Command_GetAdminNotes_ext,
expect.any(Object),
expect.objectContaining({ responseExt: Response_GetAdminNotes_ext })
);
});
it('onSuccess calls response.moderator.getAdminNotes with notes', () => {
getAdminNotes('alice');
const resp = { notes: 'some notes' };
invokeOnSuccess(resp, { responseCode: 0 });
expect(WebClient.instance.response.moderator.getAdminNotes).toHaveBeenCalledWith('alice', 'some notes');
});
});
describe('getBanHistory', () => {
it('calls sendModeratorCommand with Command_GetBanHistory', () => {
getBanHistory('alice');
expect(WebClient.instance.protobuf.sendModeratorCommand).toHaveBeenCalledWith(
Command_GetBanHistory_ext,
expect.any(Object),
expect.objectContaining({ responseExt: Response_BanHistory_ext })
);
});
it('onSuccess calls response.moderator.banHistory with banList', () => {
getBanHistory('alice');
const resp = { banList: [{ id: 1 }] };
invokeOnSuccess(resp, { responseCode: 0 });
expect(WebClient.instance.response.moderator.banHistory).toHaveBeenCalledWith('alice', [{ id: 1 }]);
});
});
describe('getWarnHistory', () => {
it('calls sendModeratorCommand with Command_GetWarnHistory', () => {
getWarnHistory('alice');
expect(WebClient.instance.protobuf.sendModeratorCommand).toHaveBeenCalledWith(
Command_GetWarnHistory_ext,
expect.any(Object),
expect.objectContaining({ responseExt: Response_WarnHistory_ext })
);
});
it('onSuccess calls response.moderator.warnHistory with warnList', () => {
getWarnHistory('alice');
const resp = { warnList: [{ id: 2 }] };
invokeOnSuccess(resp, { responseCode: 0 });
expect(WebClient.instance.response.moderator.warnHistory).toHaveBeenCalledWith('alice', [{ id: 2 }]);
});
});
describe('getWarnList', () => {
it('calls sendModeratorCommand with Command_GetWarnList', () => {
getWarnList('mod1', 'alice', 'US');
expect(WebClient.instance.protobuf.sendModeratorCommand).toHaveBeenCalledWith(
Command_GetWarnList_ext,
expect.any(Object),
expect.objectContaining({ responseExt: Response_WarnList_ext })
);
});
it('onSuccess calls response.moderator.warnListOptions with the response', () => {
getWarnList('mod1', 'alice', 'US');
const resp = { warning: ['w1', 'w2'], userName: 'alice', userClientid: 'US' };
invokeOnSuccess(resp, { responseCode: 0 });
expect(WebClient.instance.response.moderator.warnListOptions).toHaveBeenCalledWith([resp]);
});
});
describe('grantReplayAccess', () => {
it('calls sendModeratorCommand with Command_GrantReplayAccess', () => {
grantReplayAccess(10, 'mod1');
expect(WebClient.instance.protobuf.sendModeratorCommand).toHaveBeenCalledWith(
Command_GrantReplayAccess_ext, expect.any(Object), expect.any(Object)
);
});
it('onSuccess calls response.moderator.grantReplayAccess', () => {
grantReplayAccess(10, 'mod1');
invokeOnSuccess();
expect(WebClient.instance.response.moderator.grantReplayAccess).toHaveBeenCalledWith(10, 'mod1');
});
});
describe('updateAdminNotes', () => {
it('calls sendModeratorCommand with Command_UpdateAdminNotes', () => {
updateAdminNotes('alice', 'new notes');
expect(WebClient.instance.protobuf.sendModeratorCommand).toHaveBeenCalledWith(
Command_UpdateAdminNotes_ext, expect.any(Object), expect.any(Object)
);
});
it('onSuccess calls response.moderator.updateAdminNotes', () => {
updateAdminNotes('alice', 'new notes');
invokeOnSuccess();
expect(WebClient.instance.response.moderator.updateAdminNotes).toHaveBeenCalledWith('alice', 'new notes');
});
});
describe('viewLogHistory', () => {
it('calls sendModeratorCommand with Command_ViewLogHistory', () => {
const filters = create(Command_ViewLogHistorySchema, { dateRange: 7 });
viewLogHistory(filters);
expect(WebClient.instance.protobuf.sendModeratorCommand).toHaveBeenCalledWith(
Command_ViewLogHistory_ext,
expect.any(Object),
expect.objectContaining({ responseExt: Response_ViewLogHistory_ext })
);
});
it('onSuccess calls response.moderator.viewLogs with logMessage', () => {
const filters = create(Command_ViewLogHistorySchema, { dateRange: 7 });
viewLogHistory(filters);
const resp = { logMessage: ['log1'] };
invokeOnSuccess(resp, { responseCode: 0 });
expect(WebClient.instance.response.moderator.viewLogs).toHaveBeenCalledWith(['log1']);
});
});
describe('warnUser', () => {
it('calls sendModeratorCommand with Command_WarnUser', () => {
warnUser('alice', 'bad behavior', 'cid');
expect(WebClient.instance.protobuf.sendModeratorCommand).toHaveBeenCalledWith(
Command_WarnUser_ext, expect.any(Object), expect.any(Object)
);
});
it('onSuccess calls response.moderator.warnUser', () => {
warnUser('alice', 'bad behavior', 'cid');
invokeOnSuccess();
expect(WebClient.instance.response.moderator.warnUser).toHaveBeenCalledWith('alice');
});
});

View file

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

View file

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

View file

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

View file

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

View file

@ -1,4 +0,0 @@
export * from './createGame';
export * from './joinGame';
export * from './leaveRoom';
export * from './roomSay';

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,10 +0,0 @@
import { WebClient } from '../../WebClient';
import type { ConnectTarget } from '../../types/WebClientConfig';
export function connect(target: ConnectTarget): void {
WebClient.instance.connect(target);
}
export function testConnect(target: ConnectTarget): void {
WebClient.instance.testConnect(target);
}

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