mirror of
https://github.com/Cockatrice/Cockatrice.git
synced 2026-07-30 04:40:23 -07:00
Compare commits
4 commits
4b5f66d497
...
d04aa83258
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d04aa83258 | ||
|
|
53639a8448 | ||
|
|
fea21b5057 | ||
|
|
decebc25c7 |
213 changed files with 3727 additions and 1898 deletions
|
|
@ -11,7 +11,8 @@ import { createEcmaScriptPlugin, runNodeJs } from '@bufbuild/protoplugin';
|
||||||
|
|
||||||
const HEADER = [
|
const HEADER = [
|
||||||
'// @generated by protoc-gen-data. DO NOT EDIT.',
|
'// @generated by protoc-gen-data. DO NOT EDIT.',
|
||||||
'// Rollup of all proto modules + MessageInitShape param aliases for every Command_*.',
|
'// Rollup of all proto modules + MessageInitShape param aliases for every Command_*,',
|
||||||
|
'// plus type maps for Response/Event extensions grouped by scope.',
|
||||||
'/* eslint-disable */',
|
'/* eslint-disable */',
|
||||||
'',
|
'',
|
||||||
'',
|
'',
|
||||||
|
|
@ -55,6 +56,71 @@ const inner = createEcmaScriptPlugin({
|
||||||
}
|
}
|
||||||
f.print();
|
f.print();
|
||||||
|
|
||||||
|
// ── Type maps for Response/Event extensions, grouped by extendee ────────
|
||||||
|
//
|
||||||
|
// Scans all messages for nested `extend` declarations and groups them by
|
||||||
|
// which message they extend (Response, SessionEvent, RoomEvent, GameEvent).
|
||||||
|
// Emits one `interface *Map { TypeName: TypeName; ... }` per scope.
|
||||||
|
|
||||||
|
/** @type {Map<string, import('@bufbuild/protobuf').DescMessage[]>} */
|
||||||
|
const extendeeGroups = new Map();
|
||||||
|
|
||||||
|
for (const file of sortedFiles) {
|
||||||
|
for (const msg of file.messages) {
|
||||||
|
for (const ext of msg.nestedExtensions) {
|
||||||
|
const target = ext.extendee.name;
|
||||||
|
const group = extendeeGroups.get(target);
|
||||||
|
if (group) {
|
||||||
|
group.push(msg);
|
||||||
|
} else {
|
||||||
|
extendeeGroups.set(target, [msg]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @type {[string, string, import('@bufbuild/protobuf').DescMessage | null][]} */
|
||||||
|
const maps = [
|
||||||
|
['ResponseMap', 'Response', null],
|
||||||
|
['SessionEventMap', 'SessionEvent', null],
|
||||||
|
['RoomEventMap', 'RoomEvent', null],
|
||||||
|
['GameEventMap', 'GameEvent', null],
|
||||||
|
];
|
||||||
|
|
||||||
|
// Resolve the base extendee message for maps that need the base type included
|
||||||
|
for (const file of sortedFiles) {
|
||||||
|
for (const msg of file.messages) {
|
||||||
|
for (const entry of maps) {
|
||||||
|
if (msg.name === entry[1]) {
|
||||||
|
entry[2] = msg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [mapName, extendeeName, baseMsg] of maps) {
|
||||||
|
const msgs = (extendeeGroups.get(extendeeName) || []).slice();
|
||||||
|
msgs.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
|
||||||
|
if (msgs.length === 0 && !baseMsg) continue;
|
||||||
|
|
||||||
|
f.print('export interface ', mapName, ' {');
|
||||||
|
|
||||||
|
// Include the base extendee type itself (e.g. Response in ResponseMap)
|
||||||
|
if (baseMsg) {
|
||||||
|
const sym = f.import(baseMsg.name, `./proto/${baseMsg.file.name}_pb`, true);
|
||||||
|
f.print(' ', baseMsg.name, ': ', sym, ';');
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const msg of msgs) {
|
||||||
|
const sym = f.import(msg.name, `./proto/${msg.file.name}_pb`, true);
|
||||||
|
f.print(' ', msg.name, ': ', sym, ';');
|
||||||
|
}
|
||||||
|
|
||||||
|
f.print('}');
|
||||||
|
f.print();
|
||||||
|
}
|
||||||
|
|
||||||
// Generic extension registry infrastructure. Consolidates the three
|
// Generic extension registry infrastructure. Consolidates the three
|
||||||
// near-duplicate registry types and helpers that used to live in
|
// near-duplicate registry types and helpers that used to live in
|
||||||
// src/websocket/services/protobuf-types.ts into one generic pair.
|
// src/websocket/services/protobuf-types.ts into one generic pair.
|
||||||
|
|
|
||||||
|
|
@ -19,23 +19,30 @@ const types = (...types) => types.map((type) => ({ to: { type } }));
|
||||||
|
|
||||||
const rules = [
|
const rules = [
|
||||||
{ from: { type: 'generated' }, allow: [] },
|
{ from: { type: 'generated' }, allow: [] },
|
||||||
{ from: { type: 'types' }, allow: types('generated') },
|
{ from: { type: 'types' }, allow: types('generated', 'websocket') },
|
||||||
|
|
||||||
{ from: { type: 'websocket' }, allow: types('types') },
|
{ from: { type: 'websocket' }, allow: types('generated') },
|
||||||
{ from: { type: 'store' }, allow: types('types') },
|
{ from: { type: 'store' }, allow: types('types') },
|
||||||
{ from: { type: 'api' }, allow: types('types', 'store', 'websocket') },
|
{ from: { type: 'api' }, allow: types('store', 'types', 'websocket') },
|
||||||
|
|
||||||
{ from: { type: 'hooks' }, allow: types('services', 'types') },
|
{ from: { type: 'hooks' }, allow: types('api', 'services', 'types', 'websocket') },
|
||||||
{ from: { type: 'images' }, allow: types('types') },
|
{ from: { type: 'images' }, allow: types('types') },
|
||||||
{ from: { type: 'services' }, allow: types('api', 'store', 'types') },
|
{ from: { type: 'services' }, allow: types('api', 'store', 'types') },
|
||||||
|
|
||||||
{ from: { type: 'components' }, allow: types('api', 'dialogs', 'forms', 'hooks', 'images', 'services', 'types', 'store') },
|
{
|
||||||
{ from: { type: 'containers' }, allow: types('api', 'components', 'dialogs', 'forms', 'hooks', 'images', 'services', 'types', 'store') },
|
from: { type: 'components' },
|
||||||
{ from: { type: 'dialogs' }, allow: types('components', 'forms', 'hooks', 'services', 'types', 'store') },
|
allow: types('api', 'dialogs', 'forms', 'hooks', 'images', 'services', 'store', 'types')
|
||||||
{ from: { type: 'forms' }, allow: types('components', 'hooks', 'types', 'services', 'store') },
|
},
|
||||||
|
{
|
||||||
|
from: { type: 'containers' },
|
||||||
|
allow: types('api', 'components', 'dialogs', 'forms', 'hooks', 'images', 'services', 'store', 'types')
|
||||||
|
},
|
||||||
|
{ from: { type: 'dialogs' }, allow: types('components', 'forms', 'hooks', 'services', 'store', 'types') },
|
||||||
|
{ from: { type: 'forms' }, allow: types('components', 'hooks', 'services', 'store', 'types') },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const boundariesConfig = {
|
export const boundariesConfig = [
|
||||||
|
{
|
||||||
plugins: { boundaries },
|
plugins: { boundaries },
|
||||||
settings: {
|
settings: {
|
||||||
'boundaries/elements': elements,
|
'boundaries/elements': elements,
|
||||||
|
|
@ -52,4 +59,9 @@ export const boundariesConfig = {
|
||||||
rules,
|
rules,
|
||||||
}],
|
}],
|
||||||
},
|
},
|
||||||
};
|
},
|
||||||
|
{
|
||||||
|
files: ['**/*.spec.*'],
|
||||||
|
rules: { 'boundaries/dependencies': 'off' },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ export default tseslint.config(
|
||||||
...tseslint.configs.recommended,
|
...tseslint.configs.recommended,
|
||||||
|
|
||||||
// Enforce module boundaries
|
// Enforce module boundaries
|
||||||
boundariesConfig,
|
...boundariesConfig,
|
||||||
|
|
||||||
// Project-specific config
|
// Project-specific config
|
||||||
{
|
{
|
||||||
|
|
|
||||||
66
webclient/integration/src/admin.spec.ts
Normal file
66
webclient/integration/src/admin.spec.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
// Admin command pipeline smoke test — validates that sendAdminCommand
|
||||||
|
// encodes, correlates, and persists correctly end-to-end.
|
||||||
|
|
||||||
|
import { create } from '@bufbuild/protobuf';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { Data } from '@app/types';
|
||||||
|
import { store } from '@app/store';
|
||||||
|
import { AdminCommands } from '@app/websocket';
|
||||||
|
|
||||||
|
import { connectAndLogin } from './helpers/setup';
|
||||||
|
import {
|
||||||
|
buildResponse,
|
||||||
|
buildResponseMessage,
|
||||||
|
buildSessionEventMessage,
|
||||||
|
deliverMessage,
|
||||||
|
} from './helpers/protobuf-builders';
|
||||||
|
import { findLastAdminCommand } from './helpers/command-capture';
|
||||||
|
|
||||||
|
describe('admin commands', () => {
|
||||||
|
it('adjustMod modifies the user level bitflags on success', () => {
|
||||||
|
connectAndLogin();
|
||||||
|
|
||||||
|
// Add bob to the user list so the reducer has a target
|
||||||
|
deliverMessage(buildSessionEventMessage(
|
||||||
|
Data.Event_UserJoined_ext,
|
||||||
|
create(Data.Event_UserJoinedSchema, {
|
||||||
|
userInfo: create(Data.ServerInfo_UserSchema, {
|
||||||
|
name: 'bob',
|
||||||
|
userLevel: Data.ServerInfo_User_UserLevelFlag.IsRegistered,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
));
|
||||||
|
expect(store.getState().server.users.bob).toBeDefined();
|
||||||
|
|
||||||
|
AdminCommands.adjustMod('bob', true, false);
|
||||||
|
|
||||||
|
const { cmdId, value } = findLastAdminCommand(Data.Command_AdjustMod_ext);
|
||||||
|
expect(value.userName).toBe('bob');
|
||||||
|
expect(value.shouldBeMod).toBe(true);
|
||||||
|
expect(value.shouldBeJudge).toBe(false);
|
||||||
|
|
||||||
|
deliverMessage(buildResponseMessage(buildResponse({
|
||||||
|
cmdId,
|
||||||
|
responseCode: Data.Response_ResponseCode.RespOk,
|
||||||
|
})));
|
||||||
|
|
||||||
|
const bobLevel = store.getState().server.users.bob.userLevel;
|
||||||
|
expect(bobLevel & Data.ServerInfo_User_UserLevelFlag.IsModerator).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shutdownServer sends command and dispatches on success', () => {
|
||||||
|
connectAndLogin();
|
||||||
|
|
||||||
|
AdminCommands.shutdownServer('Scheduled maintenance', 10);
|
||||||
|
|
||||||
|
const { cmdId, value } = findLastAdminCommand(Data.Command_ShutdownServer_ext);
|
||||||
|
expect(value.reason).toBe('Scheduled maintenance');
|
||||||
|
expect(value.minutes).toBe(10);
|
||||||
|
|
||||||
|
deliverMessage(buildResponseMessage(buildResponse({
|
||||||
|
cmdId,
|
||||||
|
responseCode: Data.Response_ResponseCode.RespOk,
|
||||||
|
})));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,12 +1,14 @@
|
||||||
// Authentication scenarios — login success/failure, register, and activate.
|
// Authentication scenarios — login success/failure, register, activate,
|
||||||
|
// and the hashed-password (salt) login path.
|
||||||
|
|
||||||
import { create } from '@bufbuild/protobuf';
|
import { create } from '@bufbuild/protobuf';
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import { App, Data } from '@app/types';
|
import { Data } from '@app/types';
|
||||||
import { store } from '@app/store';
|
import { store } from '@app/store';
|
||||||
|
import { StatusEnum, WebSocketConnectReason } from '@app/websocket';
|
||||||
|
|
||||||
import { connectAndHandshake } from './helpers/setup';
|
import { connectAndHandshake, connectAndHandshakeWithSalt } from './helpers/setup';
|
||||||
import {
|
import {
|
||||||
buildResponse,
|
buildResponse,
|
||||||
buildResponseMessage,
|
buildResponseMessage,
|
||||||
|
|
@ -42,7 +44,7 @@ describe('authentication', () => {
|
||||||
})));
|
})));
|
||||||
|
|
||||||
const state = store.getState().server;
|
const state = store.getState().server;
|
||||||
expect(state.status.state).toBe(App.StatusEnum.LOGGED_IN);
|
expect(state.status.state).toBe(StatusEnum.LOGGED_IN);
|
||||||
expect(state.status.description).toBe('Logged in.');
|
expect(state.status.description).toBe('Logged in.');
|
||||||
expect(state.user?.name).toBe('alice');
|
expect(state.user?.name).toBe('alice');
|
||||||
expect(Object.keys(state.buddyList)).toEqual(['bob']);
|
expect(Object.keys(state.buddyList)).toEqual(['bob']);
|
||||||
|
|
@ -62,7 +64,7 @@ describe('authentication', () => {
|
||||||
})));
|
})));
|
||||||
|
|
||||||
const state = store.getState().server;
|
const state = store.getState().server;
|
||||||
expect(state.status.state).toBe(App.StatusEnum.DISCONNECTED);
|
expect(state.status.state).toBe(StatusEnum.DISCONNECTED);
|
||||||
expect(state.user).toBeNull();
|
expect(state.user).toBeNull();
|
||||||
expect(state.buddyList).toEqual({});
|
expect(state.buddyList).toEqual({});
|
||||||
});
|
});
|
||||||
|
|
@ -70,7 +72,7 @@ describe('authentication', () => {
|
||||||
|
|
||||||
describe('register', () => {
|
describe('register', () => {
|
||||||
const registerOptions = {
|
const registerOptions = {
|
||||||
reason: App.WebSocketConnectReason.REGISTER,
|
reason: WebSocketConnectReason.REGISTER as const,
|
||||||
host: 'localhost',
|
host: 'localhost',
|
||||||
port: '4748',
|
port: '4748',
|
||||||
userName: 'newbie',
|
userName: 'newbie',
|
||||||
|
|
@ -78,10 +80,10 @@ describe('authentication', () => {
|
||||||
email: 'newbie@example.com',
|
email: 'newbie@example.com',
|
||||||
country: 'US',
|
country: 'US',
|
||||||
realName: 'New Bie',
|
realName: 'New Bie',
|
||||||
} as const;
|
};
|
||||||
|
|
||||||
it('auto-logs-in on RespRegistrationAccepted', () => {
|
it('auto-logs-in on RespRegistrationAccepted', () => {
|
||||||
connectAndHandshake(registerOptions as any);
|
connectAndHandshake(registerOptions);
|
||||||
|
|
||||||
const register = findLastSessionCommand(Data.Command_Register_ext);
|
const register = findLastSessionCommand(Data.Command_Register_ext);
|
||||||
expect(register.value.userName).toBe('newbie');
|
expect(register.value.userName).toBe('newbie');
|
||||||
|
|
@ -97,7 +99,7 @@ describe('authentication', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('parks registration in awaiting-activation on RespRegistrationAcceptedNeedsActivation', () => {
|
it('parks registration in awaiting-activation on RespRegistrationAcceptedNeedsActivation', () => {
|
||||||
connectAndHandshake(registerOptions as any);
|
connectAndHandshake(registerOptions);
|
||||||
|
|
||||||
const register = findLastSessionCommand(Data.Command_Register_ext);
|
const register = findLastSessionCommand(Data.Command_Register_ext);
|
||||||
deliverMessage(buildResponseMessage(buildResponse({
|
deliverMessage(buildResponseMessage(buildResponse({
|
||||||
|
|
@ -105,7 +107,7 @@ describe('authentication', () => {
|
||||||
responseCode: Data.Response_ResponseCode.RespRegistrationAcceptedNeedsActivation,
|
responseCode: Data.Response_ResponseCode.RespRegistrationAcceptedNeedsActivation,
|
||||||
})));
|
})));
|
||||||
|
|
||||||
expect(store.getState().server.status.state).toBe(App.StatusEnum.DISCONNECTED);
|
expect(store.getState().server.status.state).toBe(StatusEnum.DISCONNECTED);
|
||||||
expect(() => findLastSessionCommand(Data.Command_Login_ext)).toThrow();
|
expect(() => findLastSessionCommand(Data.Command_Login_ext)).toThrow();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -113,13 +115,13 @@ describe('authentication', () => {
|
||||||
describe('activate', () => {
|
describe('activate', () => {
|
||||||
it('auto-logs-in on RespActivationAccepted', () => {
|
it('auto-logs-in on RespActivationAccepted', () => {
|
||||||
connectAndHandshake({
|
connectAndHandshake({
|
||||||
reason: App.WebSocketConnectReason.ACTIVATE_ACCOUNT,
|
reason: WebSocketConnectReason.ACTIVATE_ACCOUNT as const,
|
||||||
host: 'localhost',
|
host: 'localhost',
|
||||||
port: '4748',
|
port: '4748',
|
||||||
userName: 'alice',
|
userName: 'alice',
|
||||||
token: 'abc-123',
|
token: 'abc-123',
|
||||||
password: 'secret',
|
password: 'secret',
|
||||||
} as any);
|
});
|
||||||
|
|
||||||
const activate = findLastSessionCommand(Data.Command_Activate_ext);
|
const activate = findLastSessionCommand(Data.Command_Activate_ext);
|
||||||
expect(activate.value.userName).toBe('alice');
|
expect(activate.value.userName).toBe('alice');
|
||||||
|
|
@ -133,4 +135,43 @@ describe('authentication', () => {
|
||||||
expect(login.value.userName).toBe('alice');
|
expect(login.value.userName).toBe('alice');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('hashed-password login (salt path)', () => {
|
||||||
|
it('requests salt then sends login with hashedPassword instead of plaintext', () => {
|
||||||
|
connectAndHandshakeWithSalt({ userName: 'alice', password: 'secret' });
|
||||||
|
|
||||||
|
// First command should be RequestPasswordSalt, not Login
|
||||||
|
const salt = findLastSessionCommand(Data.Command_RequestPasswordSalt_ext);
|
||||||
|
expect(salt.value.userName).toBe('alice');
|
||||||
|
expect(() => findLastSessionCommand(Data.Command_Login_ext)).toThrow();
|
||||||
|
|
||||||
|
// Deliver salt response
|
||||||
|
deliverMessage(buildResponseMessage(buildResponse({
|
||||||
|
cmdId: salt.cmdId,
|
||||||
|
responseCode: Data.Response_ResponseCode.RespOk,
|
||||||
|
ext: Data.Response_PasswordSalt_ext,
|
||||||
|
value: create(Data.Response_PasswordSaltSchema, { passwordSalt: 'test-salt-value' }),
|
||||||
|
})));
|
||||||
|
|
||||||
|
// Now login should have been sent with hashedPassword
|
||||||
|
const login = findLastSessionCommand(Data.Command_Login_ext);
|
||||||
|
expect(login.value.userName).toBe('alice');
|
||||||
|
expect(login.value.hashedPassword).toBeTruthy();
|
||||||
|
expect(login.value.password).toBeFalsy();
|
||||||
|
|
||||||
|
// Complete login
|
||||||
|
deliverMessage(buildResponseMessage(buildResponse({
|
||||||
|
cmdId: login.cmdId,
|
||||||
|
responseCode: Data.Response_ResponseCode.RespOk,
|
||||||
|
ext: Data.Response_Login_ext,
|
||||||
|
value: create(Data.Response_LoginSchema, {
|
||||||
|
userInfo: makeUser('alice'),
|
||||||
|
buddyList: [],
|
||||||
|
ignoreList: [],
|
||||||
|
}),
|
||||||
|
})));
|
||||||
|
|
||||||
|
expect(store.getState().server.status.state).toBe(StatusEnum.LOGGED_IN);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,44 @@
|
||||||
// Connection-lifecycle scenarios. Exercises the full transport handshake
|
// Connection-lifecycle scenarios. Exercises the full transport handshake
|
||||||
// from `webClient.connect()` through `onopen`, ServerIdentification, and
|
// from webClient.connect() through onopen, ServerIdentification, and
|
||||||
// disconnect — with only the browser WebSocket constructor mocked.
|
// disconnect — with only the browser WebSocket constructor mocked.
|
||||||
|
|
||||||
import { create } from '@bufbuild/protobuf';
|
import { create } from '@bufbuild/protobuf';
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import { App, Data } from '@app/types';
|
import { Data } from '@app/types';
|
||||||
import { store } from '@app/store';
|
import { store } from '@app/store';
|
||||||
|
import { StatusEnum } from '@app/websocket';
|
||||||
|
|
||||||
import { PROTOCOL_VERSION } from '../../src/websocket/config';
|
import { PROTOCOL_VERSION } from '../../src/websocket/config';
|
||||||
|
|
||||||
import { getMockWebSocket, getWebClient, openMockWebSocket } from './helpers/setup';
|
import {
|
||||||
|
getMockWebSocket,
|
||||||
|
getWebClient,
|
||||||
|
openMockWebSocket,
|
||||||
|
setPendingOptions,
|
||||||
|
connectAndHandshake,
|
||||||
|
} from './helpers/setup';
|
||||||
|
import type { WebSocketConnectOptions } from '@app/websocket';
|
||||||
|
import { WebSocketConnectReason } from '@app/websocket';
|
||||||
import {
|
import {
|
||||||
buildSessionEventMessage,
|
buildSessionEventMessage,
|
||||||
deliverMessage,
|
deliverMessage,
|
||||||
} from './helpers/protobuf-builders';
|
} from './helpers/protobuf-builders';
|
||||||
import { findLastSessionCommand } from './helpers/command-capture';
|
import { findLastSessionCommand } from './helpers/command-capture';
|
||||||
|
|
||||||
function loginOptions(overrides: Partial<{ userName: string; password: string }> = {}) {
|
function loginOptions(overrides: Partial<{ userName: string; password: string }> = {}): WebSocketConnectOptions {
|
||||||
return {
|
return {
|
||||||
reason: App.WebSocketConnectReason.LOGIN,
|
reason: WebSocketConnectReason.LOGIN,
|
||||||
host: 'localhost',
|
host: 'localhost',
|
||||||
port: '4748',
|
port: '4748',
|
||||||
userName: overrides.userName ?? 'alice',
|
userName: overrides.userName ?? 'alice',
|
||||||
password: overrides.password ?? 'secret',
|
password: overrides.password ?? 'secret',
|
||||||
} as const;
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function connectWithOptions(opts: WebSocketConnectOptions): void {
|
||||||
|
setPendingOptions(opts);
|
||||||
|
getWebClient().connect({ host: opts.host, port: opts.port });
|
||||||
}
|
}
|
||||||
|
|
||||||
function serverIdentification(
|
function serverIdentification(
|
||||||
|
|
@ -43,47 +57,45 @@ function serverIdentification(
|
||||||
|
|
||||||
describe('connection lifecycle', () => {
|
describe('connection lifecycle', () => {
|
||||||
it('flips status through CONNECTING → CONNECTED on socket open', () => {
|
it('flips status through CONNECTING → CONNECTED on socket open', () => {
|
||||||
getWebClient().connect(loginOptions());
|
connectWithOptions(loginOptions());
|
||||||
|
|
||||||
expect(store.getState().server.status.connectionAttemptMade).toBe(true);
|
expect(store.getState().server.status.connectionAttemptMade).toBe(true);
|
||||||
|
|
||||||
openMockWebSocket();
|
openMockWebSocket();
|
||||||
|
|
||||||
expect(store.getState().server.status.state).toBe(App.StatusEnum.CONNECTED);
|
expect(store.getState().server.status.state).toBe(StatusEnum.CONNECTED);
|
||||||
expect(store.getState().server.status.description).toBe('Connected');
|
expect(store.getState().server.status.description).toBe('Connected');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('routes a matching ServerIdentification into LOGGING_IN and sends Command_Login', () => {
|
it('routes a matching ServerIdentification into LOGGING_IN and sends Command_Login', () => {
|
||||||
getWebClient().connect(loginOptions({ userName: 'alice' }));
|
connectWithOptions(loginOptions({ userName: 'alice' }));
|
||||||
openMockWebSocket();
|
openMockWebSocket();
|
||||||
|
|
||||||
deliverMessage(serverIdentification());
|
deliverMessage(serverIdentification());
|
||||||
|
|
||||||
expect(store.getState().server.status.state).toBe(App.StatusEnum.LOGGING_IN);
|
expect(store.getState().server.status.state).toBe(StatusEnum.LOGGING_IN);
|
||||||
expect(store.getState().server.info.name).toBe('TestServer');
|
expect(store.getState().server.info.name).toBe('TestServer');
|
||||||
expect(store.getState().server.info.version).toBe('2.8.0');
|
expect(store.getState().server.info.version).toBe('2.8.0');
|
||||||
|
|
||||||
const { value, cmdId } = findLastSessionCommand(Data.Command_Login_ext);
|
const { value, cmdId } = findLastSessionCommand(Data.Command_Login_ext);
|
||||||
expect(value.userName).toBe('alice');
|
expect(value.userName).toBe('alice');
|
||||||
expect(cmdId).toBeGreaterThan(0);
|
expect(cmdId).toBeGreaterThan(0);
|
||||||
|
|
||||||
expect(getWebClient().options).toBeNull();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('disconnects on protocol version mismatch without sending a login command', () => {
|
it('disconnects on protocol version mismatch without sending a login command', () => {
|
||||||
getWebClient().connect(loginOptions());
|
connectWithOptions(loginOptions());
|
||||||
openMockWebSocket();
|
openMockWebSocket();
|
||||||
|
|
||||||
deliverMessage(serverIdentification(PROTOCOL_VERSION + 1));
|
deliverMessage(serverIdentification(PROTOCOL_VERSION + 1));
|
||||||
|
|
||||||
const mock = getMockWebSocket();
|
const mock = getMockWebSocket();
|
||||||
expect(mock.close).toHaveBeenCalled();
|
expect(mock.close).toHaveBeenCalled();
|
||||||
expect(store.getState().server.status.state).toBe(App.StatusEnum.DISCONNECTED);
|
expect(store.getState().server.status.state).toBe(StatusEnum.DISCONNECTED);
|
||||||
expect(() => findLastSessionCommand(Data.Command_Login_ext)).toThrow();
|
expect(() => findLastSessionCommand(Data.Command_Login_ext)).toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('times out when onopen never fires within the keepalive window', () => {
|
it('times out when onopen never fires within the keepalive window', () => {
|
||||||
getWebClient().connect(loginOptions());
|
connectWithOptions(loginOptions());
|
||||||
|
|
||||||
const mock = getMockWebSocket();
|
const mock = getMockWebSocket();
|
||||||
expect(mock.close).not.toHaveBeenCalled();
|
expect(mock.close).not.toHaveBeenCalled();
|
||||||
|
|
@ -91,11 +103,11 @@ describe('connection lifecycle', () => {
|
||||||
vi.advanceTimersByTime(5000);
|
vi.advanceTimersByTime(5000);
|
||||||
|
|
||||||
expect(mock.close).toHaveBeenCalled();
|
expect(mock.close).toHaveBeenCalled();
|
||||||
expect(store.getState().server.status.state).toBe(App.StatusEnum.DISCONNECTED);
|
expect(store.getState().server.status.state).toBe(StatusEnum.DISCONNECTED);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('releases keep-alive ping loop on explicit disconnect', () => {
|
it('releases keep-alive ping loop on explicit disconnect', () => {
|
||||||
getWebClient().connect(loginOptions());
|
connectWithOptions(loginOptions());
|
||||||
openMockWebSocket();
|
openMockWebSocket();
|
||||||
deliverMessage(serverIdentification());
|
deliverMessage(serverIdentification());
|
||||||
|
|
||||||
|
|
@ -103,6 +115,20 @@ describe('connection lifecycle', () => {
|
||||||
getWebClient().disconnect();
|
getWebClient().disconnect();
|
||||||
|
|
||||||
expect(mock.close).toHaveBeenCalled();
|
expect(mock.close).toHaveBeenCalled();
|
||||||
expect(store.getState().server.status.state).toBe(App.StatusEnum.DISCONNECTED);
|
expect(store.getState().server.status.state).toBe(StatusEnum.DISCONNECTED);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops pending commands and clears state on unexpected socket close', () => {
|
||||||
|
connectAndHandshake();
|
||||||
|
|
||||||
|
// A login command is now pending (sent during handshake)
|
||||||
|
expect(() => findLastSessionCommand(Data.Command_Login_ext)).not.toThrow();
|
||||||
|
|
||||||
|
// Simulate unexpected socket close
|
||||||
|
const mock = getMockWebSocket();
|
||||||
|
mock.readyState = 3;
|
||||||
|
mock.onclose?.({ code: 1006, reason: '', wasClean: false } as CloseEvent);
|
||||||
|
|
||||||
|
expect(store.getState().server.status.state).toBe(StatusEnum.DISCONNECTED);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
119
webclient/integration/src/deck.spec.ts
Normal file
119
webclient/integration/src/deck.spec.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
// Deck and replay command round-trips — validates the session command pipeline
|
||||||
|
// for deck CRUD and replay operations end-to-end.
|
||||||
|
|
||||||
|
import { create } from '@bufbuild/protobuf';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { Data } from '@app/types';
|
||||||
|
import { store } from '@app/store';
|
||||||
|
import { SessionCommands } from '@app/websocket';
|
||||||
|
|
||||||
|
import { connectAndLogin } from './helpers/setup';
|
||||||
|
import {
|
||||||
|
buildResponse,
|
||||||
|
buildResponseMessage,
|
||||||
|
deliverMessage,
|
||||||
|
} from './helpers/protobuf-builders';
|
||||||
|
import { findLastSessionCommand } from './helpers/command-capture';
|
||||||
|
|
||||||
|
describe('deck operations', () => {
|
||||||
|
it('populates backendDecks from deckList response', () => {
|
||||||
|
connectAndLogin();
|
||||||
|
|
||||||
|
SessionCommands.deckList();
|
||||||
|
|
||||||
|
const { cmdId } = findLastSessionCommand(Data.Command_DeckList_ext);
|
||||||
|
|
||||||
|
const deckFile = create(Data.ServerInfo_DeckStorage_TreeItemSchema, {
|
||||||
|
id: 1,
|
||||||
|
name: 'MyDeck.cod',
|
||||||
|
file: create(Data.ServerInfo_DeckStorage_FileSchema, { creationTime: 1000 }),
|
||||||
|
});
|
||||||
|
const root = create(Data.ServerInfo_DeckStorage_FolderSchema, {
|
||||||
|
items: [deckFile],
|
||||||
|
});
|
||||||
|
deliverMessage(buildResponseMessage(buildResponse({
|
||||||
|
cmdId,
|
||||||
|
responseCode: Data.Response_ResponseCode.RespOk,
|
||||||
|
ext: Data.Response_DeckList_ext,
|
||||||
|
value: create(Data.Response_DeckListSchema, { root }),
|
||||||
|
})));
|
||||||
|
|
||||||
|
const backendDecks = store.getState().server.backendDecks;
|
||||||
|
expect(backendDecks).not.toBeNull();
|
||||||
|
expect(backendDecks?.root?.items).toHaveLength(1);
|
||||||
|
expect(backendDecks?.root?.items[0]?.name).toBe('MyDeck.cod');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('populates downloadedDeck from deckDownload response', () => {
|
||||||
|
connectAndLogin();
|
||||||
|
|
||||||
|
SessionCommands.deckDownload(42);
|
||||||
|
|
||||||
|
const { cmdId } = findLastSessionCommand(Data.Command_DeckDownload_ext);
|
||||||
|
|
||||||
|
deliverMessage(buildResponseMessage(buildResponse({
|
||||||
|
cmdId,
|
||||||
|
responseCode: Data.Response_ResponseCode.RespOk,
|
||||||
|
ext: Data.Response_DeckDownload_ext,
|
||||||
|
value: create(Data.Response_DeckDownloadSchema, { deck: '4 Lightning Bolt\n20 Mountain' }),
|
||||||
|
})));
|
||||||
|
|
||||||
|
const downloaded = store.getState().server.downloadedDeck;
|
||||||
|
expect(downloaded).not.toBeNull();
|
||||||
|
expect(downloaded?.deckId).toBe(42);
|
||||||
|
expect(downloaded?.deck).toContain('Lightning Bolt');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('replay operations', () => {
|
||||||
|
it('populates replays from replayList response', () => {
|
||||||
|
connectAndLogin();
|
||||||
|
|
||||||
|
SessionCommands.replayList();
|
||||||
|
|
||||||
|
const { cmdId } = findLastSessionCommand(Data.Command_ReplayList_ext);
|
||||||
|
|
||||||
|
const match = create(Data.ServerInfo_ReplayMatchSchema, {
|
||||||
|
gameId: 99,
|
||||||
|
gameName: 'Casual Game',
|
||||||
|
});
|
||||||
|
deliverMessage(buildResponseMessage(buildResponse({
|
||||||
|
cmdId,
|
||||||
|
responseCode: Data.Response_ResponseCode.RespOk,
|
||||||
|
ext: Data.Response_ReplayList_ext,
|
||||||
|
value: create(Data.Response_ReplayListSchema, { matchList: [match] }),
|
||||||
|
})));
|
||||||
|
|
||||||
|
const replays = store.getState().server.replays;
|
||||||
|
expect(replays[99]).toBeDefined();
|
||||||
|
expect(replays[99].gameName).toBe('Casual Game');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes replay from state on replayDeleteMatch round-trip', () => {
|
||||||
|
connectAndLogin();
|
||||||
|
|
||||||
|
// First populate a replay
|
||||||
|
SessionCommands.replayList();
|
||||||
|
const list = findLastSessionCommand(Data.Command_ReplayList_ext);
|
||||||
|
deliverMessage(buildResponseMessage(buildResponse({
|
||||||
|
cmdId: list.cmdId,
|
||||||
|
responseCode: Data.Response_ResponseCode.RespOk,
|
||||||
|
ext: Data.Response_ReplayList_ext,
|
||||||
|
value: create(Data.Response_ReplayListSchema, {
|
||||||
|
matchList: [create(Data.ServerInfo_ReplayMatchSchema, { gameId: 99, gameName: 'Old Game' })],
|
||||||
|
}),
|
||||||
|
})));
|
||||||
|
expect(store.getState().server.replays[99]).toBeDefined();
|
||||||
|
|
||||||
|
// Now delete it
|
||||||
|
SessionCommands.replayDeleteMatch(99);
|
||||||
|
const del = findLastSessionCommand(Data.Command_ReplayDeleteMatch_ext);
|
||||||
|
deliverMessage(buildResponseMessage(buildResponse({
|
||||||
|
cmdId: del.cmdId,
|
||||||
|
responseCode: Data.Response_ResponseCode.RespOk,
|
||||||
|
})));
|
||||||
|
|
||||||
|
expect(store.getState().server.replays[99]).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
416
webclient/integration/src/game.spec.ts
Normal file
416
webclient/integration/src/game.spec.ts
Normal file
|
|
@ -0,0 +1,416 @@
|
||||||
|
// Game scenarios — game join, state initialization, card operations,
|
||||||
|
// player counters, game chat, game close, and outbound game commands.
|
||||||
|
|
||||||
|
import { create } from '@bufbuild/protobuf';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { Data } from '@app/types';
|
||||||
|
import { store } from '@app/store';
|
||||||
|
import { GameCommands, RoomCommands } from '@app/websocket';
|
||||||
|
|
||||||
|
import { connectAndHandshake, connectAndLogin } from './helpers/setup';
|
||||||
|
import {
|
||||||
|
buildResponse,
|
||||||
|
buildResponseMessage,
|
||||||
|
buildSessionEventMessage,
|
||||||
|
buildRoomEventMessage,
|
||||||
|
buildGameEventMessage,
|
||||||
|
deliverMessage,
|
||||||
|
} from './helpers/protobuf-builders';
|
||||||
|
import { findLastGameCommand, findLastRoomCommand, findLastSessionCommand } from './helpers/command-capture';
|
||||||
|
|
||||||
|
function joinGame(gameId: number): void {
|
||||||
|
deliverMessage(buildSessionEventMessage(
|
||||||
|
Data.Event_GameJoined_ext,
|
||||||
|
create(Data.Event_GameJoinedSchema, {
|
||||||
|
gameInfo: create(Data.ServerInfo_GameSchema, {
|
||||||
|
gameId,
|
||||||
|
description: 'Test Game',
|
||||||
|
maxPlayers: 2,
|
||||||
|
playerCount: 1,
|
||||||
|
}),
|
||||||
|
playerId: 1,
|
||||||
|
hostId: 1,
|
||||||
|
spectator: false,
|
||||||
|
judge: false,
|
||||||
|
resuming: false,
|
||||||
|
})
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupGameState(gameId: number): void {
|
||||||
|
const deckCard = create(Data.ServerInfo_CardSchema, { id: 100, name: 'Forest' });
|
||||||
|
const handCard = create(Data.ServerInfo_CardSchema, { id: 101, name: 'Lightning Bolt' });
|
||||||
|
|
||||||
|
const deckZone = create(Data.ServerInfo_ZoneSchema, {
|
||||||
|
name: 'deck',
|
||||||
|
type: Data.ServerInfo_Zone_ZoneType.HiddenZone,
|
||||||
|
cardList: [deckCard],
|
||||||
|
});
|
||||||
|
const handZone = create(Data.ServerInfo_ZoneSchema, {
|
||||||
|
name: 'hand',
|
||||||
|
type: Data.ServerInfo_Zone_ZoneType.HiddenZone,
|
||||||
|
cardList: [handCard],
|
||||||
|
});
|
||||||
|
const tableZone = create(Data.ServerInfo_ZoneSchema, {
|
||||||
|
name: 'table',
|
||||||
|
type: Data.ServerInfo_Zone_ZoneType.PublicZone,
|
||||||
|
withCoords: true,
|
||||||
|
cardList: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const player = create(Data.ServerInfo_PlayerSchema, {
|
||||||
|
properties: create(Data.ServerInfo_PlayerPropertiesSchema, {
|
||||||
|
playerId: 1,
|
||||||
|
userInfo: create(Data.ServerInfo_UserSchema, { name: 'alice' }),
|
||||||
|
}),
|
||||||
|
zoneList: [deckZone, handZone, tableZone],
|
||||||
|
counterList: [],
|
||||||
|
arrowList: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
deliverMessage(buildGameEventMessage({
|
||||||
|
gameId,
|
||||||
|
playerId: -1,
|
||||||
|
ext: Data.Event_GameStateChanged_ext,
|
||||||
|
value: create(Data.Event_GameStateChangedSchema, {
|
||||||
|
playerList: [player],
|
||||||
|
gameStarted: true,
|
||||||
|
activePlayerId: 1,
|
||||||
|
activePhase: 0,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('game', () => {
|
||||||
|
it('initializes game state from Event_GameJoined + Event_GameStateChanged', () => {
|
||||||
|
connectAndLogin();
|
||||||
|
joinGame(42);
|
||||||
|
|
||||||
|
const game = store.getState().games.games[42];
|
||||||
|
expect(game).toBeDefined();
|
||||||
|
expect(game.info.description).toBe('Test Game');
|
||||||
|
expect(game.localPlayerId).toBe(1);
|
||||||
|
|
||||||
|
setupGameState(42);
|
||||||
|
|
||||||
|
const updated = store.getState().games.games[42];
|
||||||
|
expect(updated.started).toBe(true);
|
||||||
|
expect(updated.activePlayerId).toBe(1);
|
||||||
|
expect(updated.players[1]).toBeDefined();
|
||||||
|
expect(updated.players[1].zones.hand).toBeDefined();
|
||||||
|
expect(updated.players[1].zones.deck).toBeDefined();
|
||||||
|
expect(updated.players[1].zones.hand.order).toContain(101);
|
||||||
|
expect(updated.players[1].zones.deck.order).toContain(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('draws cards from deck to hand on Event_DrawCards', () => {
|
||||||
|
connectAndLogin();
|
||||||
|
joinGame(42);
|
||||||
|
setupGameState(42);
|
||||||
|
|
||||||
|
const drawnCard = create(Data.ServerInfo_CardSchema, { id: 200, name: 'Mountain' });
|
||||||
|
deliverMessage(buildGameEventMessage({
|
||||||
|
gameId: 42,
|
||||||
|
playerId: 1,
|
||||||
|
ext: Data.Event_DrawCards_ext,
|
||||||
|
value: create(Data.Event_DrawCardsSchema, {
|
||||||
|
number: 1,
|
||||||
|
cards: [drawnCard],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const player = store.getState().games.games[42].players[1];
|
||||||
|
expect(player.zones.hand.order).toContain(200);
|
||||||
|
expect(player.zones.hand.byId[200]?.name).toBe('Mountain');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('appends chat messages on Event_GameSay', () => {
|
||||||
|
connectAndLogin();
|
||||||
|
joinGame(42);
|
||||||
|
|
||||||
|
deliverMessage(buildGameEventMessage({
|
||||||
|
gameId: 42,
|
||||||
|
playerId: 1,
|
||||||
|
ext: Data.Event_GameSay_ext,
|
||||||
|
value: create(Data.Event_GameSaySchema, { message: 'good game' }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const messages = store.getState().games.games[42].messages;
|
||||||
|
expect(messages).toHaveLength(1);
|
||||||
|
expect(messages[0].message).toBe('good game');
|
||||||
|
expect(messages[0].playerId).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes game from store on Event_GameClosed', () => {
|
||||||
|
connectAndLogin();
|
||||||
|
joinGame(42);
|
||||||
|
|
||||||
|
expect(store.getState().games.games[42]).toBeDefined();
|
||||||
|
|
||||||
|
deliverMessage(buildGameEventMessage({
|
||||||
|
gameId: 42,
|
||||||
|
playerId: -1,
|
||||||
|
ext: Data.Event_GameClosed_ext,
|
||||||
|
value: create(Data.Event_GameClosedSchema),
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(store.getState().games.games[42]).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends outbound Command_GameSay with correct gameId and message', () => {
|
||||||
|
connectAndLogin();
|
||||||
|
joinGame(42);
|
||||||
|
|
||||||
|
GameCommands.gameSay(42, { message: 'hello opponent' });
|
||||||
|
|
||||||
|
const { value, cmdId } = findLastGameCommand(Data.Command_GameSay_ext);
|
||||||
|
expect(value.message).toBe('hello opponent');
|
||||||
|
expect(cmdId).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('moves a card from hand to table on Event_MoveCard', () => {
|
||||||
|
connectAndLogin();
|
||||||
|
joinGame(42);
|
||||||
|
setupGameState(42);
|
||||||
|
|
||||||
|
deliverMessage(buildGameEventMessage({
|
||||||
|
gameId: 42,
|
||||||
|
playerId: 1,
|
||||||
|
ext: Data.Event_MoveCard_ext,
|
||||||
|
value: create(Data.Event_MoveCardSchema, {
|
||||||
|
cardId: 101,
|
||||||
|
cardName: 'Lightning Bolt',
|
||||||
|
startPlayerId: 1,
|
||||||
|
startZone: 'hand',
|
||||||
|
targetPlayerId: 1,
|
||||||
|
targetZone: 'table',
|
||||||
|
x: 100,
|
||||||
|
y: 200,
|
||||||
|
faceDown: false,
|
||||||
|
newCardId: 101,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const player = store.getState().games.games[42].players[1];
|
||||||
|
expect(player.zones.hand.order).not.toContain(101);
|
||||||
|
expect(player.zones.table.order).toContain(101);
|
||||||
|
expect(player.zones.table.byId[101]?.name).toBe('Lightning Bolt');
|
||||||
|
expect(player.zones.table.byId[101]?.x).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates and updates player counters', () => {
|
||||||
|
connectAndLogin();
|
||||||
|
joinGame(42);
|
||||||
|
setupGameState(42);
|
||||||
|
|
||||||
|
const counterInfo = create(Data.ServerInfo_CounterSchema, {
|
||||||
|
id: 1,
|
||||||
|
name: 'Life',
|
||||||
|
count: 20,
|
||||||
|
radius: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
deliverMessage(buildGameEventMessage({
|
||||||
|
gameId: 42,
|
||||||
|
playerId: 1,
|
||||||
|
ext: Data.Event_CreateCounter_ext,
|
||||||
|
value: create(Data.Event_CreateCounterSchema, { counterInfo }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const player = store.getState().games.games[42].players[1];
|
||||||
|
expect(player.counters[1]).toBeDefined();
|
||||||
|
expect(player.counters[1].name).toBe('Life');
|
||||||
|
expect(player.counters[1].count).toBe(20);
|
||||||
|
|
||||||
|
deliverMessage(buildGameEventMessage({
|
||||||
|
gameId: 42,
|
||||||
|
playerId: 1,
|
||||||
|
ext: Data.Event_SetCounter_ext,
|
||||||
|
value: create(Data.Event_SetCounterSchema, { counterId: 1, value: 17 }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(store.getState().games.games[42].players[1].counters[1].count).toBe(17);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('full lifecycle: create → join → deck select → draw → chat → discard → concede → leave', () => {
|
||||||
|
connectAndHandshake();
|
||||||
|
|
||||||
|
// ── Setup: join a room so we can create a game in it ──────────────────
|
||||||
|
deliverMessage(buildSessionEventMessage(
|
||||||
|
Data.Event_ListRooms_ext,
|
||||||
|
create(Data.Event_ListRoomsSchema, {
|
||||||
|
roomList: [create(Data.ServerInfo_RoomSchema, { roomId: 1, autoJoin: true, gameList: [], userList: [], gametypeList: [] })],
|
||||||
|
})
|
||||||
|
));
|
||||||
|
const roomJoin = findLastSessionCommand(Data.Command_JoinRoom_ext);
|
||||||
|
deliverMessage(buildResponseMessage(buildResponse({
|
||||||
|
cmdId: roomJoin.cmdId,
|
||||||
|
responseCode: Data.Response_ResponseCode.RespOk,
|
||||||
|
ext: Data.Response_JoinRoom_ext,
|
||||||
|
value: create(Data.Response_JoinRoomSchema, {
|
||||||
|
roomInfo: create(Data.ServerInfo_RoomSchema, { roomId: 1, gameList: [], userList: [], gametypeList: [] }),
|
||||||
|
}),
|
||||||
|
})));
|
||||||
|
|
||||||
|
// ── 1. Create game ───────────────────────────────────────────────────
|
||||||
|
RoomCommands.createGame(1, { description: 'Ranked Match', maxPlayers: 2 });
|
||||||
|
const createCmd = findLastRoomCommand(Data.Command_CreateGame_ext);
|
||||||
|
deliverMessage(buildResponseMessage(buildResponse({
|
||||||
|
cmdId: createCmd.cmdId,
|
||||||
|
responseCode: Data.Response_ResponseCode.RespOk,
|
||||||
|
})));
|
||||||
|
|
||||||
|
// ── 2. Join game ─────────────────────────────────────────────────────
|
||||||
|
RoomCommands.joinGame(1, { gameId: 99 });
|
||||||
|
const joinCmd = findLastRoomCommand(Data.Command_JoinGame_ext);
|
||||||
|
deliverMessage(buildResponseMessage(buildResponse({
|
||||||
|
cmdId: joinCmd.cmdId,
|
||||||
|
responseCode: Data.Response_ResponseCode.RespOk,
|
||||||
|
})));
|
||||||
|
expect(store.getState().rooms.joinedGameIds[1]?.[99]).toBe(true);
|
||||||
|
|
||||||
|
// Server sends Event_GameJoined (session event)
|
||||||
|
deliverMessage(buildSessionEventMessage(
|
||||||
|
Data.Event_GameJoined_ext,
|
||||||
|
create(Data.Event_GameJoinedSchema, {
|
||||||
|
gameInfo: create(Data.ServerInfo_GameSchema, { gameId: 99, description: 'Ranked Match', maxPlayers: 2 }),
|
||||||
|
playerId: 1,
|
||||||
|
hostId: 1,
|
||||||
|
spectator: false,
|
||||||
|
judge: false,
|
||||||
|
resuming: false,
|
||||||
|
})
|
||||||
|
));
|
||||||
|
expect(store.getState().games.games[99]).toBeDefined();
|
||||||
|
|
||||||
|
// ── 3. Select deck ───────────────────────────────────────────────────
|
||||||
|
GameCommands.deckSelect(99, { deck: '4 Lightning Bolt\n20 Mountain\n4 Goblin Guide' });
|
||||||
|
const deckCmd = findLastGameCommand(Data.Command_DeckSelect_ext);
|
||||||
|
expect(deckCmd.value.deck).toContain('Lightning Bolt');
|
||||||
|
|
||||||
|
// Server responds with full game state (deck in zones)
|
||||||
|
const deckCards = [
|
||||||
|
create(Data.ServerInfo_CardSchema, { id: 1, name: 'Lightning Bolt' }),
|
||||||
|
create(Data.ServerInfo_CardSchema, { id: 2, name: 'Mountain' }),
|
||||||
|
create(Data.ServerInfo_CardSchema, { id: 3, name: 'Goblin Guide' }),
|
||||||
|
];
|
||||||
|
deliverMessage(buildGameEventMessage({
|
||||||
|
gameId: 99,
|
||||||
|
playerId: -1,
|
||||||
|
ext: Data.Event_GameStateChanged_ext,
|
||||||
|
value: create(Data.Event_GameStateChangedSchema, {
|
||||||
|
playerList: [create(Data.ServerInfo_PlayerSchema, {
|
||||||
|
properties: create(Data.ServerInfo_PlayerPropertiesSchema, {
|
||||||
|
playerId: 1,
|
||||||
|
userInfo: create(Data.ServerInfo_UserSchema, { name: 'alice' }),
|
||||||
|
}),
|
||||||
|
zoneList: [
|
||||||
|
create(Data.ServerInfo_ZoneSchema, { name: 'deck', type: Data.ServerInfo_Zone_ZoneType.HiddenZone, cardList: deckCards, cardCount: 3 }),
|
||||||
|
create(Data.ServerInfo_ZoneSchema, { name: 'hand', type: Data.ServerInfo_Zone_ZoneType.HiddenZone, cardList: [], cardCount: 0 }),
|
||||||
|
create(Data.ServerInfo_ZoneSchema, { name: 'table', type: Data.ServerInfo_Zone_ZoneType.PublicZone, withCoords: true, cardList: [], cardCount: 0 }),
|
||||||
|
create(Data.ServerInfo_ZoneSchema, { name: 'grave', type: Data.ServerInfo_Zone_ZoneType.PublicZone, cardList: [], cardCount: 0 }),
|
||||||
|
],
|
||||||
|
counterList: [],
|
||||||
|
arrowList: [],
|
||||||
|
})],
|
||||||
|
gameStarted: true,
|
||||||
|
activePlayerId: 1,
|
||||||
|
activePhase: 0,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const gameAfterDeck = store.getState().games.games[99];
|
||||||
|
expect(gameAfterDeck.players[1].zones.deck.order).toHaveLength(3);
|
||||||
|
expect(gameAfterDeck.players[1].zones.hand.order).toHaveLength(0);
|
||||||
|
|
||||||
|
// ── 4. Draw cards ────────────────────────────────────────────────────
|
||||||
|
deliverMessage(buildGameEventMessage({
|
||||||
|
gameId: 99,
|
||||||
|
playerId: 1,
|
||||||
|
ext: Data.Event_DrawCards_ext,
|
||||||
|
value: create(Data.Event_DrawCardsSchema, {
|
||||||
|
number: 2,
|
||||||
|
cards: [
|
||||||
|
create(Data.ServerInfo_CardSchema, { id: 1, name: 'Lightning Bolt' }),
|
||||||
|
create(Data.ServerInfo_CardSchema, { id: 2, name: 'Mountain' }),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const afterDraw = store.getState().games.games[99].players[1];
|
||||||
|
expect(afterDraw.zones.hand.order).toHaveLength(2);
|
||||||
|
expect(afterDraw.zones.hand.order).toContain(1);
|
||||||
|
expect(afterDraw.zones.hand.order).toContain(2);
|
||||||
|
expect(afterDraw.zones.deck.cardCount).toBe(1);
|
||||||
|
|
||||||
|
// ── 5. Send game message ─────────────────────────────────────────────
|
||||||
|
GameCommands.gameSay(99, { message: 'good luck!' });
|
||||||
|
const sayCmd = findLastGameCommand(Data.Command_GameSay_ext);
|
||||||
|
expect(sayCmd.value.message).toBe('good luck!');
|
||||||
|
|
||||||
|
// Server echoes the message back
|
||||||
|
deliverMessage(buildGameEventMessage({
|
||||||
|
gameId: 99,
|
||||||
|
playerId: 1,
|
||||||
|
ext: Data.Event_GameSay_ext,
|
||||||
|
value: create(Data.Event_GameSaySchema, { message: 'good luck!' }),
|
||||||
|
}));
|
||||||
|
expect(store.getState().games.games[99].messages).toHaveLength(1);
|
||||||
|
|
||||||
|
// ── 6. Discard (move card from hand to graveyard) ────────────────────
|
||||||
|
deliverMessage(buildGameEventMessage({
|
||||||
|
gameId: 99,
|
||||||
|
playerId: 1,
|
||||||
|
ext: Data.Event_MoveCard_ext,
|
||||||
|
value: create(Data.Event_MoveCardSchema, {
|
||||||
|
cardId: 1,
|
||||||
|
cardName: 'Lightning Bolt',
|
||||||
|
startPlayerId: 1,
|
||||||
|
startZone: 'hand',
|
||||||
|
targetPlayerId: 1,
|
||||||
|
targetZone: 'grave',
|
||||||
|
faceDown: false,
|
||||||
|
newCardId: 1,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const afterDiscard = store.getState().games.games[99].players[1];
|
||||||
|
expect(afterDiscard.zones.hand.order).not.toContain(1);
|
||||||
|
expect(afterDiscard.zones.grave.order).toContain(1);
|
||||||
|
expect(afterDiscard.zones.grave.byId[1]?.name).toBe('Lightning Bolt');
|
||||||
|
|
||||||
|
// ── 7. Concede ───────────────────────────────────────────────────────
|
||||||
|
GameCommands.concede(99);
|
||||||
|
expect(() => findLastGameCommand(Data.Command_Concede_ext)).not.toThrow();
|
||||||
|
|
||||||
|
// Server confirms concession
|
||||||
|
deliverMessage(buildGameEventMessage({
|
||||||
|
gameId: 99,
|
||||||
|
playerId: 1,
|
||||||
|
ext: Data.Event_PlayerPropertiesChanged_ext,
|
||||||
|
value: create(Data.Event_PlayerPropertiesChangedSchema, {
|
||||||
|
playerProperties: create(Data.ServerInfo_PlayerPropertiesSchema, {
|
||||||
|
playerId: 1,
|
||||||
|
conceded: true,
|
||||||
|
userInfo: create(Data.ServerInfo_UserSchema, { name: 'alice' }),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
expect(store.getState().games.games[99].players[1].properties.conceded).toBe(true);
|
||||||
|
|
||||||
|
// ── 8. Leave game ────────────────────────────────────────────────────
|
||||||
|
GameCommands.leaveGame(99);
|
||||||
|
expect(() => findLastGameCommand(Data.Command_LeaveGame_ext)).not.toThrow();
|
||||||
|
|
||||||
|
// Server confirms player left
|
||||||
|
deliverMessage(buildGameEventMessage({
|
||||||
|
gameId: 99,
|
||||||
|
playerId: 1,
|
||||||
|
ext: Data.Event_Leave_ext,
|
||||||
|
value: create(Data.Event_LeaveSchema, { reason: Data.Event_Leave_LeaveReason.USER_LEFT }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(store.getState().games.games[99].players[1]).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -11,10 +11,12 @@ import { Data } from '@app/types';
|
||||||
|
|
||||||
import { getMockWebSocket } from './setup';
|
import { getMockWebSocket } from './setup';
|
||||||
|
|
||||||
/** The three command scopes a CommandContainer can carry in practice. */
|
/** The command scopes a CommandContainer can carry in practice. */
|
||||||
type SessionCmd = Data.SessionCommand;
|
type SessionCmd = Data.SessionCommand;
|
||||||
type RoomCmd = Data.RoomCommand;
|
type RoomCmd = Data.RoomCommand;
|
||||||
type GameCmd = Data.GameCommand;
|
type GameCmd = Data.GameCommand;
|
||||||
|
type AdminCmd = Data.AdminCommand;
|
||||||
|
type ModeratorCmd = Data.ModeratorCommand;
|
||||||
|
|
||||||
/** Decode every CommandContainer sent through the mock socket so far. */
|
/** Decode every CommandContainer sent through the mock socket so far. */
|
||||||
export function captureAllOutbound(): Data.CommandContainer[] {
|
export function captureAllOutbound(): Data.CommandContainer[] {
|
||||||
|
|
@ -110,3 +112,47 @@ export function findLastGameCommand<V>(
|
||||||
`No outbound game command with extension ${ext.typeName} has been sent.`
|
`No outbound game command with extension ${ext.typeName} has been sent.`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Admin-scoped equivalent of {@link findLastSessionCommand}. */
|
||||||
|
export function findLastAdminCommand<V>(
|
||||||
|
ext: GenExtension<AdminCmd, V>
|
||||||
|
): { container: Data.CommandContainer; value: V; cmdId: number } {
|
||||||
|
const containers = captureAllOutbound();
|
||||||
|
for (let i = containers.length - 1; i >= 0; i--) {
|
||||||
|
const container = containers[i];
|
||||||
|
for (const adminCmd of container.adminCommand ?? []) {
|
||||||
|
if (hasExtension(adminCmd, ext)) {
|
||||||
|
return {
|
||||||
|
container,
|
||||||
|
value: getExtension(adminCmd, ext),
|
||||||
|
cmdId: Number(container.cmdId),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error(
|
||||||
|
`No outbound admin command with extension ${ext.typeName} has been sent.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Moderator-scoped equivalent of {@link findLastSessionCommand}. */
|
||||||
|
export function findLastModeratorCommand<V>(
|
||||||
|
ext: GenExtension<ModeratorCmd, V>
|
||||||
|
): { container: Data.CommandContainer; value: V; cmdId: number } {
|
||||||
|
const containers = captureAllOutbound();
|
||||||
|
for (let i = containers.length - 1; i >= 0; i--) {
|
||||||
|
const container = containers[i];
|
||||||
|
for (const modCmd of container.moderatorCommand ?? []) {
|
||||||
|
if (hasExtension(modCmd, ext)) {
|
||||||
|
return {
|
||||||
|
container,
|
||||||
|
value: getExtension(modCmd, ext),
|
||||||
|
cmdId: Number(container.cmdId),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error(
|
||||||
|
`No outbound moderator command with extension ${ext.typeName} has been sent.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,10 +14,16 @@ import { create } from '@bufbuild/protobuf';
|
||||||
import { afterEach, beforeEach, vi } from 'vitest';
|
import { afterEach, beforeEach, vi } from 'vitest';
|
||||||
|
|
||||||
import { ServerDispatch, RoomsDispatch, GameDispatch } from '@app/store';
|
import { ServerDispatch, RoomsDispatch, GameDispatch } from '@app/store';
|
||||||
import { App, Data, Enriched } from '@app/types';
|
import { Data } from '@app/types';
|
||||||
import { WebClient } from '@app/websocket';
|
import {
|
||||||
|
WebClient,
|
||||||
|
StatusEnum,
|
||||||
|
WebSocketConnectReason,
|
||||||
|
setPendingOptions,
|
||||||
|
} from '@app/websocket';
|
||||||
|
import type { WebSocketConnectOptions } from '@app/websocket';
|
||||||
import { PROTOCOL_VERSION } from '../../../src/websocket/config';
|
import { PROTOCOL_VERSION } from '../../../src/websocket/config';
|
||||||
import { createWebClientResponse, createWebClientRequest } from '@app/api';
|
import { createWebClientRequest, createWebClientResponse } from '@app/api';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
buildResponse,
|
buildResponse,
|
||||||
|
|
@ -27,6 +33,8 @@ import {
|
||||||
} from './protobuf-builders';
|
} from './protobuf-builders';
|
||||||
import { findLastSessionCommand } from './command-capture';
|
import { findLastSessionCommand } from './command-capture';
|
||||||
|
|
||||||
|
export { setPendingOptions };
|
||||||
|
|
||||||
export interface MockWebSocketInstance {
|
export interface MockWebSocketInstance {
|
||||||
send: ReturnType<typeof vi.fn>;
|
send: ReturnType<typeof vi.fn>;
|
||||||
close: ReturnType<typeof vi.fn>;
|
close: ReturnType<typeof vi.fn>;
|
||||||
|
|
@ -97,8 +105,7 @@ function resetAll(): void {
|
||||||
}
|
}
|
||||||
|
|
||||||
client.protobuf.resetCommands();
|
client.protobuf.resetCommands();
|
||||||
client.options = null;
|
client.status = StatusEnum.DISCONNECTED;
|
||||||
client.status = App.StatusEnum.DISCONNECTED;
|
|
||||||
|
|
||||||
ServerDispatch.clearStore();
|
ServerDispatch.clearStore();
|
||||||
RoomsDispatch.clearStore();
|
RoomsDispatch.clearStore();
|
||||||
|
|
@ -117,8 +124,8 @@ function resetAll(): void {
|
||||||
|
|
||||||
// ── Shared connect helpers ──────────────────────────────────────────────────
|
// ── Shared connect helpers ──────────────────────────────────────────────────
|
||||||
|
|
||||||
const DEFAULT_LOGIN_OPTIONS: Enriched.LoginConnectOptions = {
|
const DEFAULT_LOGIN_OPTIONS: WebSocketConnectOptions = {
|
||||||
reason: App.WebSocketConnectReason.LOGIN,
|
reason: WebSocketConnectReason.LOGIN,
|
||||||
host: 'localhost',
|
host: 'localhost',
|
||||||
port: '4748',
|
port: '4748',
|
||||||
userName: 'alice',
|
userName: 'alice',
|
||||||
|
|
@ -126,14 +133,16 @@ const DEFAULT_LOGIN_OPTIONS: Enriched.LoginConnectOptions = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export function connectRaw(
|
export function connectRaw(
|
||||||
overrides: Partial<Enriched.LoginConnectOptions> = {}
|
overrides: Partial<WebSocketConnectOptions> = {}
|
||||||
): void {
|
): void {
|
||||||
getWebClient().connect({ ...DEFAULT_LOGIN_OPTIONS, ...overrides });
|
const opts = { ...DEFAULT_LOGIN_OPTIONS, ...overrides };
|
||||||
|
setPendingOptions(opts as WebSocketConnectOptions);
|
||||||
|
getWebClient().connect({ host: opts.host, port: opts.port });
|
||||||
openMockWebSocket();
|
openMockWebSocket();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function connectAndHandshake(
|
export function connectAndHandshake(
|
||||||
overrides: Partial<Enriched.LoginConnectOptions> = {}
|
overrides: Partial<WebSocketConnectOptions> = {}
|
||||||
): void {
|
): void {
|
||||||
connectRaw(overrides);
|
connectRaw(overrides);
|
||||||
deliverMessage(buildSessionEventMessage(
|
deliverMessage(buildSessionEventMessage(
|
||||||
|
|
@ -146,6 +155,21 @@ export function connectAndHandshake(
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function connectAndHandshakeWithSalt(
|
||||||
|
overrides: Partial<WebSocketConnectOptions> = {}
|
||||||
|
): void {
|
||||||
|
connectRaw(overrides);
|
||||||
|
deliverMessage(buildSessionEventMessage(
|
||||||
|
Data.Event_ServerIdentification_ext,
|
||||||
|
create(Data.Event_ServerIdentificationSchema, {
|
||||||
|
serverName: 'TestServer',
|
||||||
|
serverVersion: '2.8.0',
|
||||||
|
protocolVersion: PROTOCOL_VERSION,
|
||||||
|
serverOptions: Data.Event_ServerIdentification_ServerOptions.SupportsPasswordHash,
|
||||||
|
})
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
export function connectAndLogin(userName: string = 'alice'): void {
|
export function connectAndLogin(userName: string = 'alice'): void {
|
||||||
connectAndHandshake({ userName });
|
connectAndHandshake({ userName });
|
||||||
|
|
||||||
|
|
@ -172,7 +196,7 @@ installMockWebSocket();
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
new WebClient(createWebClientResponse(), createWebClientRequest());
|
new WebClient(createWebClientRequest(), createWebClientResponse());
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,9 @@
|
||||||
|
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import { App, Data } from '@app/types';
|
import { Data } from '@app/types';
|
||||||
import { store } from '@app/store';
|
import { store } from '@app/store';
|
||||||
|
import { StatusEnum } from '@app/websocket';
|
||||||
|
|
||||||
import { connectRaw, getMockWebSocket } from './helpers/setup';
|
import { connectRaw, getMockWebSocket } from './helpers/setup';
|
||||||
import {
|
import {
|
||||||
|
|
@ -31,7 +32,7 @@ describe('keep-alive', () => {
|
||||||
vi.advanceTimersByTime(5000);
|
vi.advanceTimersByTime(5000);
|
||||||
const second = findLastSessionCommand(Data.Command_Ping_ext);
|
const second = findLastSessionCommand(Data.Command_Ping_ext);
|
||||||
expect(second.cmdId).toBeGreaterThan(first.cmdId);
|
expect(second.cmdId).toBeGreaterThan(first.cmdId);
|
||||||
expect(store.getState().server.status.state).toBe(App.StatusEnum.CONNECTED);
|
expect(store.getState().server.status.state).toBe(StatusEnum.CONNECTED);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('stays CONNECTED while pongs arrive before the next tick', () => {
|
it('stays CONNECTED while pongs arrive before the next tick', () => {
|
||||||
|
|
@ -46,7 +47,7 @@ describe('keep-alive', () => {
|
||||||
})));
|
})));
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(store.getState().server.status.state).toBe(App.StatusEnum.CONNECTED);
|
expect(store.getState().server.status.state).toBe(StatusEnum.CONNECTED);
|
||||||
expect(getMockWebSocket().close).not.toHaveBeenCalled();
|
expect(getMockWebSocket().close).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -55,11 +56,11 @@ describe('keep-alive', () => {
|
||||||
|
|
||||||
vi.advanceTimersByTime(5000);
|
vi.advanceTimersByTime(5000);
|
||||||
expect(() => findLastSessionCommand(Data.Command_Ping_ext)).not.toThrow();
|
expect(() => findLastSessionCommand(Data.Command_Ping_ext)).not.toThrow();
|
||||||
expect(store.getState().server.status.state).toBe(App.StatusEnum.CONNECTED);
|
expect(store.getState().server.status.state).toBe(StatusEnum.CONNECTED);
|
||||||
|
|
||||||
vi.advanceTimersByTime(5000);
|
vi.advanceTimersByTime(5000);
|
||||||
|
|
||||||
expect(getMockWebSocket().close).toHaveBeenCalled();
|
expect(getMockWebSocket().close).toHaveBeenCalled();
|
||||||
expect(store.getState().server.status.state).toBe(App.StatusEnum.DISCONNECTED);
|
expect(store.getState().server.status.state).toBe(StatusEnum.DISCONNECTED);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
104
webclient/integration/src/moderator.spec.ts
Normal file
104
webclient/integration/src/moderator.spec.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
// Moderator command pipeline smoke tests — validates that sendModeratorCommand
|
||||||
|
// encodes, correlates, and persists correctly end-to-end. One test per
|
||||||
|
// distinct response pattern (simple vs. extension-payload).
|
||||||
|
|
||||||
|
import { create } from '@bufbuild/protobuf';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { Data } from '@app/types';
|
||||||
|
import { store } from '@app/store';
|
||||||
|
import { ModeratorCommands } from '@app/websocket';
|
||||||
|
|
||||||
|
import { connectAndLogin } from './helpers/setup';
|
||||||
|
import {
|
||||||
|
buildResponse,
|
||||||
|
buildResponseMessage,
|
||||||
|
deliverMessage,
|
||||||
|
} from './helpers/protobuf-builders';
|
||||||
|
import { findLastModeratorCommand } from './helpers/command-capture';
|
||||||
|
|
||||||
|
describe('moderator commands', () => {
|
||||||
|
it('getBanHistory populates server.banHistory on success', () => {
|
||||||
|
connectAndLogin();
|
||||||
|
|
||||||
|
ModeratorCommands.getBanHistory('baduser');
|
||||||
|
|
||||||
|
const { cmdId, value } = findLastModeratorCommand(Data.Command_GetBanHistory_ext);
|
||||||
|
expect(value.userName).toBe('baduser');
|
||||||
|
|
||||||
|
const banEntry = create(Data.ServerInfo_BanSchema, {
|
||||||
|
adminId: 'admin1',
|
||||||
|
adminName: 'Admin',
|
||||||
|
banTime: '2026-01-01',
|
||||||
|
banLength: '60',
|
||||||
|
visibleReason: 'spamming',
|
||||||
|
});
|
||||||
|
deliverMessage(buildResponseMessage(buildResponse({
|
||||||
|
cmdId,
|
||||||
|
responseCode: Data.Response_ResponseCode.RespOk,
|
||||||
|
ext: Data.Response_BanHistory_ext,
|
||||||
|
value: create(Data.Response_BanHistorySchema, { banList: [banEntry] }),
|
||||||
|
})));
|
||||||
|
|
||||||
|
const history = store.getState().server.banHistory.baduser;
|
||||||
|
expect(history).toHaveLength(1);
|
||||||
|
expect(history[0].visibleReason).toBe('spamming');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('viewLogHistory populates server.logs on success', () => {
|
||||||
|
connectAndLogin();
|
||||||
|
|
||||||
|
ModeratorCommands.viewLogHistory({ dateRange: 30 });
|
||||||
|
|
||||||
|
const { cmdId } = findLastModeratorCommand(Data.Command_ViewLogHistory_ext);
|
||||||
|
|
||||||
|
const logMsg = create(Data.ServerInfo_ChatMessageSchema, {
|
||||||
|
senderName: 'alice',
|
||||||
|
message: 'test message',
|
||||||
|
});
|
||||||
|
deliverMessage(buildResponseMessage(buildResponse({
|
||||||
|
cmdId,
|
||||||
|
responseCode: Data.Response_ResponseCode.RespOk,
|
||||||
|
ext: Data.Response_ViewLogHistory_ext,
|
||||||
|
value: create(Data.Response_ViewLogHistorySchema, { logMessage: [logMsg] }),
|
||||||
|
})));
|
||||||
|
|
||||||
|
const logs = store.getState().server.logs;
|
||||||
|
expect(Object.keys(logs).length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('warnUser sends command and updates state on success', () => {
|
||||||
|
connectAndLogin();
|
||||||
|
|
||||||
|
ModeratorCommands.warnUser('troublemaker', 'spamming chat');
|
||||||
|
|
||||||
|
const { cmdId, value } = findLastModeratorCommand(Data.Command_WarnUser_ext);
|
||||||
|
expect(value.userName).toBe('troublemaker');
|
||||||
|
expect(value.reason).toBe('spamming chat');
|
||||||
|
|
||||||
|
deliverMessage(buildResponseMessage(buildResponse({
|
||||||
|
cmdId,
|
||||||
|
responseCode: Data.Response_ResponseCode.RespOk,
|
||||||
|
})));
|
||||||
|
|
||||||
|
expect(store.getState().server.warnUser).toBe('troublemaker');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('banFromServer sends command and updates state on success', () => {
|
||||||
|
connectAndLogin();
|
||||||
|
|
||||||
|
ModeratorCommands.banFromServer(60, 'baduser', undefined, 'repeated offenses', 'rule violation');
|
||||||
|
|
||||||
|
const { cmdId, value } = findLastModeratorCommand(Data.Command_BanFromServer_ext);
|
||||||
|
expect(value.userName).toBe('baduser');
|
||||||
|
expect(value.minutes).toBe(60);
|
||||||
|
expect(value.visibleReason).toBe('rule violation');
|
||||||
|
|
||||||
|
deliverMessage(buildResponseMessage(buildResponse({
|
||||||
|
cmdId,
|
||||||
|
responseCode: Data.Response_ResponseCode.RespOk,
|
||||||
|
})));
|
||||||
|
|
||||||
|
expect(store.getState().server.banUser).toBe('baduser');
|
||||||
|
});
|
||||||
|
});
|
||||||
86
webclient/integration/src/password-reset.spec.ts
Normal file
86
webclient/integration/src/password-reset.spec.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
// Password-reset scenarios — the 3-step forgot-password flow. Each step
|
||||||
|
// is a separate connect → handshake → command → disconnect cycle.
|
||||||
|
|
||||||
|
import { create } from '@bufbuild/protobuf';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { Data } from '@app/types';
|
||||||
|
import { store } from '@app/store';
|
||||||
|
import { StatusEnum, WebSocketConnectReason } from '@app/websocket';
|
||||||
|
|
||||||
|
import { connectAndHandshake } from './helpers/setup';
|
||||||
|
import {
|
||||||
|
buildResponse,
|
||||||
|
buildResponseMessage,
|
||||||
|
deliverMessage,
|
||||||
|
} from './helpers/protobuf-builders';
|
||||||
|
import { findLastSessionCommand } from './helpers/command-capture';
|
||||||
|
|
||||||
|
describe('password reset', () => {
|
||||||
|
it('forgotPasswordRequest sends command and disconnects on success', () => {
|
||||||
|
connectAndHandshake({
|
||||||
|
reason: WebSocketConnectReason.PASSWORD_RESET_REQUEST as const,
|
||||||
|
host: 'localhost',
|
||||||
|
port: '4748',
|
||||||
|
userName: 'alice',
|
||||||
|
});
|
||||||
|
|
||||||
|
const req = findLastSessionCommand(Data.Command_ForgotPasswordRequest_ext);
|
||||||
|
expect(req.value.userName).toBe('alice');
|
||||||
|
|
||||||
|
deliverMessage(buildResponseMessage(buildResponse({
|
||||||
|
cmdId: req.cmdId,
|
||||||
|
responseCode: Data.Response_ResponseCode.RespOk,
|
||||||
|
ext: Data.Response_ForgotPasswordRequest_ext,
|
||||||
|
value: create(Data.Response_ForgotPasswordRequestSchema, {
|
||||||
|
challengeEmail: 'a@example.com',
|
||||||
|
}),
|
||||||
|
})));
|
||||||
|
|
||||||
|
expect(store.getState().server.status.state).toBe(StatusEnum.DISCONNECTED);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forgotPasswordChallenge sends command with userName and email', () => {
|
||||||
|
connectAndHandshake({
|
||||||
|
reason: WebSocketConnectReason.PASSWORD_RESET_CHALLENGE as const,
|
||||||
|
host: 'localhost',
|
||||||
|
port: '4748',
|
||||||
|
userName: 'alice',
|
||||||
|
email: 'alice@example.com',
|
||||||
|
});
|
||||||
|
|
||||||
|
const challenge = findLastSessionCommand(Data.Command_ForgotPasswordChallenge_ext);
|
||||||
|
expect(challenge.value.userName).toBe('alice');
|
||||||
|
expect(challenge.value.email).toBe('alice@example.com');
|
||||||
|
|
||||||
|
deliverMessage(buildResponseMessage(buildResponse({
|
||||||
|
cmdId: challenge.cmdId,
|
||||||
|
responseCode: Data.Response_ResponseCode.RespOk,
|
||||||
|
})));
|
||||||
|
|
||||||
|
expect(store.getState().server.status.state).toBe(StatusEnum.DISCONNECTED);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forgotPasswordReset sends command with userName, token, and newPassword', () => {
|
||||||
|
connectAndHandshake({
|
||||||
|
reason: WebSocketConnectReason.PASSWORD_RESET as const,
|
||||||
|
host: 'localhost',
|
||||||
|
port: '4748',
|
||||||
|
userName: 'alice',
|
||||||
|
token: 'reset-token-123',
|
||||||
|
newPassword: 'new-secret',
|
||||||
|
});
|
||||||
|
|
||||||
|
const reset = findLastSessionCommand(Data.Command_ForgotPasswordReset_ext);
|
||||||
|
expect(reset.value.userName).toBe('alice');
|
||||||
|
expect(reset.value.token).toBe('reset-token-123');
|
||||||
|
expect(reset.value.newPassword).toBe('new-secret');
|
||||||
|
|
||||||
|
deliverMessage(buildResponseMessage(buildResponse({
|
||||||
|
cmdId: reset.cmdId,
|
||||||
|
responseCode: Data.Response_ResponseCode.RespOk,
|
||||||
|
})));
|
||||||
|
|
||||||
|
expect(store.getState().server.status.state).toBe(StatusEnum.DISCONNECTED);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
// Room scenarios — Event_ListRooms handling, auto-join, Response_JoinRoom,
|
// Room scenarios — Event_ListRooms handling, auto-join, Response_JoinRoom,
|
||||||
// room chat, and in-room game list updates.
|
// room chat (inbound + outbound), game list updates, and leaveRoom.
|
||||||
|
|
||||||
import { create } from '@bufbuild/protobuf';
|
import { create } from '@bufbuild/protobuf';
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import { Data } from '@app/types';
|
import { Data } from '@app/types';
|
||||||
import { store } from '@app/store';
|
import { store } from '@app/store';
|
||||||
|
import { RoomCommands } from '@app/websocket';
|
||||||
|
|
||||||
import { connectAndHandshake } from './helpers/setup';
|
import { connectAndHandshake } from './helpers/setup';
|
||||||
import {
|
import {
|
||||||
|
|
@ -15,7 +16,8 @@ import {
|
||||||
buildSessionEventMessage,
|
buildSessionEventMessage,
|
||||||
deliverMessage,
|
deliverMessage,
|
||||||
} from './helpers/protobuf-builders';
|
} from './helpers/protobuf-builders';
|
||||||
import { findLastSessionCommand } from './helpers/command-capture';
|
import { findLastSessionCommand, findLastRoomCommand, captureAllOutbound } from './helpers/command-capture';
|
||||||
|
import { fromBinary, hasExtension, getExtension } from '@bufbuild/protobuf';
|
||||||
|
|
||||||
function makeRoom(overrides: Partial<{
|
function makeRoom(overrides: Partial<{
|
||||||
roomId: number;
|
roomId: number;
|
||||||
|
|
@ -35,6 +37,21 @@ function makeRoom(overrides: Partial<{
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Deliver Event_ListRooms then join a single auto-join room, returning the roomId. */
|
||||||
|
function setupJoinedRoom(roomId = 1): void {
|
||||||
|
deliverMessage(buildSessionEventMessage(
|
||||||
|
Data.Event_ListRooms_ext,
|
||||||
|
create(Data.Event_ListRoomsSchema, { roomList: [makeRoom({ roomId, autoJoin: true })] })
|
||||||
|
));
|
||||||
|
const join = findLastSessionCommand(Data.Command_JoinRoom_ext);
|
||||||
|
deliverMessage(buildResponseMessage(buildResponse({
|
||||||
|
cmdId: join.cmdId,
|
||||||
|
responseCode: Data.Response_ResponseCode.RespOk,
|
||||||
|
ext: Data.Response_JoinRoom_ext,
|
||||||
|
value: create(Data.Response_JoinRoomSchema, { roomInfo: makeRoom({ roomId }) }),
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
|
||||||
describe('rooms', () => {
|
describe('rooms', () => {
|
||||||
it('populates rooms state from Event_ListRooms', () => {
|
it('populates rooms state from Event_ListRooms', () => {
|
||||||
connectAndHandshake();
|
connectAndHandshake();
|
||||||
|
|
@ -81,18 +98,7 @@ describe('rooms', () => {
|
||||||
|
|
||||||
it('appends a room chat message on Event_RoomSay', () => {
|
it('appends a room chat message on Event_RoomSay', () => {
|
||||||
connectAndHandshake();
|
connectAndHandshake();
|
||||||
|
setupJoinedRoom(1);
|
||||||
deliverMessage(buildSessionEventMessage(
|
|
||||||
Data.Event_ListRooms_ext,
|
|
||||||
create(Data.Event_ListRoomsSchema, { roomList: [makeRoom({ roomId: 1, autoJoin: true })] })
|
|
||||||
));
|
|
||||||
const join = findLastSessionCommand(Data.Command_JoinRoom_ext);
|
|
||||||
deliverMessage(buildResponseMessage(buildResponse({
|
|
||||||
cmdId: join.cmdId,
|
|
||||||
responseCode: Data.Response_ResponseCode.RespOk,
|
|
||||||
ext: Data.Response_JoinRoom_ext,
|
|
||||||
value: create(Data.Response_JoinRoomSchema, { roomInfo: makeRoom({ roomId: 1 }) }),
|
|
||||||
})));
|
|
||||||
|
|
||||||
const say = create(Data.Event_RoomSaySchema, {
|
const say = create(Data.Event_RoomSaySchema, {
|
||||||
name: 'bob',
|
name: 'bob',
|
||||||
|
|
@ -109,18 +115,7 @@ describe('rooms', () => {
|
||||||
|
|
||||||
it('updates the game list on Event_ListGames', () => {
|
it('updates the game list on Event_ListGames', () => {
|
||||||
connectAndHandshake();
|
connectAndHandshake();
|
||||||
|
setupJoinedRoom(1);
|
||||||
deliverMessage(buildSessionEventMessage(
|
|
||||||
Data.Event_ListRooms_ext,
|
|
||||||
create(Data.Event_ListRoomsSchema, { roomList: [makeRoom({ roomId: 1, autoJoin: true })] })
|
|
||||||
));
|
|
||||||
const join = findLastSessionCommand(Data.Command_JoinRoom_ext);
|
|
||||||
deliverMessage(buildResponseMessage(buildResponse({
|
|
||||||
cmdId: join.cmdId,
|
|
||||||
responseCode: Data.Response_ResponseCode.RespOk,
|
|
||||||
ext: Data.Response_JoinRoom_ext,
|
|
||||||
value: create(Data.Response_JoinRoomSchema, { roomInfo: makeRoom({ roomId: 1 }) }),
|
|
||||||
})));
|
|
||||||
|
|
||||||
const game = create(Data.ServerInfo_GameSchema, {
|
const game = create(Data.ServerInfo_GameSchema, {
|
||||||
gameId: 42,
|
gameId: 42,
|
||||||
|
|
@ -137,4 +132,102 @@ describe('rooms', () => {
|
||||||
expect(roomGames?.[42]?.info?.description).toBe('Test Game');
|
expect(roomGames?.[42]?.info?.description).toBe('Test Game');
|
||||||
expect(roomGames?.[42]?.info?.gameId).toBe(42);
|
expect(roomGames?.[42]?.info?.gameId).toBe(42);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('auto-join filters correctly across multiple rooms', () => {
|
||||||
|
connectAndHandshake();
|
||||||
|
|
||||||
|
deliverMessage(buildSessionEventMessage(
|
||||||
|
Data.Event_ListRooms_ext,
|
||||||
|
create(Data.Event_ListRoomsSchema, {
|
||||||
|
roomList: [
|
||||||
|
makeRoom({ roomId: 1, name: 'Lobby', autoJoin: true }),
|
||||||
|
makeRoom({ roomId: 2, name: 'Legacy', autoJoin: false }),
|
||||||
|
makeRoom({ roomId: 3, name: 'Modern', autoJoin: true }),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
));
|
||||||
|
|
||||||
|
// Count outbound JoinRoom commands
|
||||||
|
const containers = captureAllOutbound();
|
||||||
|
const joinCommands: number[] = [];
|
||||||
|
for (const container of containers) {
|
||||||
|
for (const cmd of container.sessionCommand ?? []) {
|
||||||
|
if (hasExtension(cmd, Data.Command_JoinRoom_ext)) {
|
||||||
|
joinCommands.push(getExtension(cmd, Data.Command_JoinRoom_ext).roomId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(joinCommands).toHaveLength(2);
|
||||||
|
expect(joinCommands).toContain(1);
|
||||||
|
expect(joinCommands).toContain(3);
|
||||||
|
expect(joinCommands).not.toContain(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends outbound Command_RoomSay with trimmed message', () => {
|
||||||
|
connectAndHandshake();
|
||||||
|
setupJoinedRoom(1);
|
||||||
|
|
||||||
|
RoomCommands.roomSay(1, ' hello ');
|
||||||
|
|
||||||
|
const { value } = findLastRoomCommand(Data.Command_RoomSay_ext);
|
||||||
|
expect(value.message).toBe('hello');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes room from joinedRoomIds on leaveRoom round-trip', () => {
|
||||||
|
connectAndHandshake();
|
||||||
|
setupJoinedRoom(1);
|
||||||
|
expect(store.getState().rooms.joinedRoomIds[1]).toBe(true);
|
||||||
|
|
||||||
|
RoomCommands.leaveRoom(1);
|
||||||
|
|
||||||
|
const leave = findLastRoomCommand(Data.Command_LeaveRoom_ext);
|
||||||
|
deliverMessage(buildResponseMessage(buildResponse({
|
||||||
|
cmdId: leave.cmdId,
|
||||||
|
responseCode: Data.Response_ResponseCode.RespOk,
|
||||||
|
})));
|
||||||
|
|
||||||
|
expect(store.getState().rooms.joinedRoomIds[1]).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tracks user join and leave within a room', () => {
|
||||||
|
connectAndHandshake();
|
||||||
|
setupJoinedRoom(1);
|
||||||
|
|
||||||
|
deliverMessage(buildRoomEventMessage(1, Data.Event_JoinRoom_ext, create(Data.Event_JoinRoomSchema, {
|
||||||
|
userInfo: create(Data.ServerInfo_UserSchema, { name: 'bob' }),
|
||||||
|
})));
|
||||||
|
|
||||||
|
expect(store.getState().rooms.rooms[1]?.users?.bob).toBeDefined();
|
||||||
|
|
||||||
|
deliverMessage(buildRoomEventMessage(1, Data.Event_LeaveRoom_ext, create(Data.Event_LeaveRoomSchema, {
|
||||||
|
name: 'bob',
|
||||||
|
})));
|
||||||
|
|
||||||
|
expect(store.getState().rooms.rooms[1]?.users?.bob).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tracks game creation and join within a room', () => {
|
||||||
|
connectAndHandshake();
|
||||||
|
setupJoinedRoom(1);
|
||||||
|
|
||||||
|
RoomCommands.createGame(1, { description: 'Casual', maxPlayers: 2 });
|
||||||
|
|
||||||
|
const create_ = findLastRoomCommand(Data.Command_CreateGame_ext);
|
||||||
|
expect(create_.value.description).toBe('Casual');
|
||||||
|
|
||||||
|
deliverMessage(buildResponseMessage(buildResponse({
|
||||||
|
cmdId: create_.cmdId,
|
||||||
|
responseCode: Data.Response_ResponseCode.RespOk,
|
||||||
|
})));
|
||||||
|
|
||||||
|
RoomCommands.joinGame(1, { gameId: 99 });
|
||||||
|
|
||||||
|
const join = findLastRoomCommand(Data.Command_JoinGame_ext);
|
||||||
|
deliverMessage(buildResponseMessage(buildResponse({
|
||||||
|
cmdId: join.cmdId,
|
||||||
|
responseCode: Data.Response_ResponseCode.RespOk,
|
||||||
|
})));
|
||||||
|
|
||||||
|
expect(store.getState().rooms.joinedGameIds[1]?.[99]).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -4,8 +4,9 @@
|
||||||
import { create } from '@bufbuild/protobuf';
|
import { create } from '@bufbuild/protobuf';
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import { App, Data } from '@app/types';
|
import { Data } from '@app/types';
|
||||||
import { store } from '@app/store';
|
import { store } from '@app/store';
|
||||||
|
import { StatusEnum } from '@app/websocket';
|
||||||
|
|
||||||
import { connectAndHandshake } from './helpers/setup';
|
import { connectAndHandshake } from './helpers/setup';
|
||||||
import {
|
import {
|
||||||
|
|
@ -72,7 +73,7 @@ describe('server events', () => {
|
||||||
));
|
));
|
||||||
|
|
||||||
const status = store.getState().server.status;
|
const status = store.getState().server.status;
|
||||||
expect(status.state).toBe(App.StatusEnum.DISCONNECTED);
|
expect(status.state).toBe(StatusEnum.DISCONNECTED);
|
||||||
expect(status.description).toBe('kicked by admin');
|
expect(status.description).toBe('kicked by admin');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,35 +1,2 @@
|
||||||
import { WebClient } from '@app/websocket';
|
|
||||||
import type { IWebClientRequest } from '@app/websocket';
|
|
||||||
|
|
||||||
export { createWebClientResponse } from './response';
|
export { createWebClientResponse } from './response';
|
||||||
export { createWebClientRequest } from './request';
|
export { createWebClientRequest } from './request';
|
||||||
|
|
||||||
/**
|
|
||||||
* UI-facing request surface. Each property is a lazy getter that resolves
|
|
||||||
* `WebClient.instance` at call time, so consumers can import this before the
|
|
||||||
* singleton is bootstrapped — it only needs to exist by the first actual call.
|
|
||||||
*
|
|
||||||
* Prefer this over importing `WebClient` directly: it keeps UI code free of
|
|
||||||
* transport-layer names and makes `@app/websocket` an internal detail of the
|
|
||||||
* `api` layer.
|
|
||||||
*/
|
|
||||||
export const request: IWebClientRequest = {
|
|
||||||
get authentication() {
|
|
||||||
return WebClient.instance.request.authentication;
|
|
||||||
},
|
|
||||||
get session() {
|
|
||||||
return WebClient.instance.request.session;
|
|
||||||
},
|
|
||||||
get rooms() {
|
|
||||||
return WebClient.instance.request.rooms;
|
|
||||||
},
|
|
||||||
get admin() {
|
|
||||||
return WebClient.instance.request.admin;
|
|
||||||
},
|
|
||||||
get moderator() {
|
|
||||||
return WebClient.instance.request.moderator;
|
|
||||||
},
|
|
||||||
get game() {
|
|
||||||
return WebClient.instance.request.game;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
|
||||||
|
|
@ -1,34 +1,71 @@
|
||||||
import { App, Enriched } from '@app/types';
|
import {
|
||||||
import type { IAuthenticationRequest } from '@app/websocket';
|
WebClient,
|
||||||
import { SessionCommands } from '@app/websocket';
|
StatusEnum,
|
||||||
|
SessionCommands,
|
||||||
|
WebSocketConnectReason,
|
||||||
|
setPendingOptions,
|
||||||
|
} from '@app/websocket';
|
||||||
|
import type {
|
||||||
|
IAuthenticationRequest,
|
||||||
|
AuthRequestMap,
|
||||||
|
LoginConnectOptions,
|
||||||
|
TestConnectionOptions,
|
||||||
|
RegisterConnectOptions,
|
||||||
|
ActivateConnectOptions,
|
||||||
|
PasswordResetRequestConnectOptions,
|
||||||
|
PasswordResetChallengeConnectOptions,
|
||||||
|
PasswordResetConnectOptions,
|
||||||
|
} from '@app/websocket';
|
||||||
|
|
||||||
export class AuthenticationRequestImpl implements IAuthenticationRequest {
|
interface AppAuthRequestOverrides extends AuthRequestMap {
|
||||||
login(options: Omit<Enriched.LoginConnectOptions, 'reason'>): void {
|
LoginParams: Omit<LoginConnectOptions, 'reason'>;
|
||||||
SessionCommands.connect({ ...options, reason: App.WebSocketConnectReason.LOGIN });
|
ConnectTarget: Omit<TestConnectionOptions, 'reason'>;
|
||||||
|
RegisterParams: Omit<RegisterConnectOptions, 'reason'>;
|
||||||
|
ActivateParams: Omit<ActivateConnectOptions, 'reason'>;
|
||||||
|
ForgotPasswordRequestParams: Omit<PasswordResetRequestConnectOptions, 'reason'>;
|
||||||
|
ForgotPasswordChallengeParams: Omit<PasswordResetChallengeConnectOptions, 'reason'>;
|
||||||
|
ForgotPasswordResetParams: Omit<PasswordResetConnectOptions, 'reason'>;
|
||||||
}
|
}
|
||||||
|
|
||||||
testConnection(options: Omit<Enriched.TestConnectionOptions, 'reason'>): void {
|
export class AuthenticationRequestImpl implements IAuthenticationRequest<AppAuthRequestOverrides> {
|
||||||
SessionCommands.connect({ ...options, reason: App.WebSocketConnectReason.TEST_CONNECTION });
|
login(options: Omit<LoginConnectOptions, 'reason'>): void {
|
||||||
|
setPendingOptions({ ...options, reason: WebSocketConnectReason.LOGIN });
|
||||||
|
SessionCommands.updateStatus(StatusEnum.CONNECTING, 'Connecting...');
|
||||||
|
WebClient.instance.connect({ host: options.host, port: options.port });
|
||||||
}
|
}
|
||||||
|
|
||||||
register(options: Omit<Enriched.RegisterConnectOptions, 'reason'>): void {
|
testConnection(options: Omit<TestConnectionOptions, 'reason'>): void {
|
||||||
SessionCommands.connect({ ...options, reason: App.WebSocketConnectReason.REGISTER });
|
WebClient.instance.testConnect({ host: options.host, port: options.port });
|
||||||
}
|
}
|
||||||
|
|
||||||
activateAccount(options: Omit<Enriched.ActivateConnectOptions, 'reason'>): void {
|
register(options: Omit<RegisterConnectOptions, 'reason'>): void {
|
||||||
SessionCommands.connect({ ...options, reason: App.WebSocketConnectReason.ACTIVATE_ACCOUNT });
|
setPendingOptions({ ...options, reason: WebSocketConnectReason.REGISTER });
|
||||||
|
SessionCommands.updateStatus(StatusEnum.CONNECTING, 'Connecting...');
|
||||||
|
WebClient.instance.connect({ host: options.host, port: options.port });
|
||||||
}
|
}
|
||||||
|
|
||||||
resetPasswordRequest(options: Omit<Enriched.PasswordResetRequestConnectOptions, 'reason'>): void {
|
activateAccount(options: Omit<ActivateConnectOptions, 'reason'>): void {
|
||||||
SessionCommands.connect({ ...options, reason: App.WebSocketConnectReason.PASSWORD_RESET_REQUEST });
|
setPendingOptions({ ...options, reason: WebSocketConnectReason.ACTIVATE_ACCOUNT });
|
||||||
|
SessionCommands.updateStatus(StatusEnum.CONNECTING, 'Connecting...');
|
||||||
|
WebClient.instance.connect({ host: options.host, port: options.port });
|
||||||
}
|
}
|
||||||
|
|
||||||
resetPasswordChallenge(options: Omit<Enriched.PasswordResetChallengeConnectOptions, 'reason'>): void {
|
resetPasswordRequest(options: Omit<PasswordResetRequestConnectOptions, 'reason'>): void {
|
||||||
SessionCommands.connect({ ...options, reason: App.WebSocketConnectReason.PASSWORD_RESET_CHALLENGE });
|
setPendingOptions({ ...options, reason: WebSocketConnectReason.PASSWORD_RESET_REQUEST });
|
||||||
|
SessionCommands.updateStatus(StatusEnum.CONNECTING, 'Connecting...');
|
||||||
|
WebClient.instance.connect({ host: options.host, port: options.port });
|
||||||
}
|
}
|
||||||
|
|
||||||
resetPassword(options: Omit<Enriched.PasswordResetConnectOptions, 'reason'>): void {
|
resetPasswordChallenge(options: Omit<PasswordResetChallengeConnectOptions, 'reason'>): void {
|
||||||
SessionCommands.connect({ ...options, reason: App.WebSocketConnectReason.PASSWORD_RESET });
|
setPendingOptions({ ...options, reason: WebSocketConnectReason.PASSWORD_RESET_CHALLENGE });
|
||||||
|
SessionCommands.updateStatus(StatusEnum.CONNECTING, 'Connecting...');
|
||||||
|
WebClient.instance.connect({ host: options.host, port: options.port });
|
||||||
|
}
|
||||||
|
|
||||||
|
resetPassword(options: Omit<PasswordResetConnectOptions, 'reason'>): void {
|
||||||
|
setPendingOptions({ ...options, reason: WebSocketConnectReason.PASSWORD_RESET });
|
||||||
|
SessionCommands.updateStatus(StatusEnum.CONNECTING, 'Connecting...');
|
||||||
|
WebClient.instance.connect({ host: options.host, port: options.port });
|
||||||
}
|
}
|
||||||
|
|
||||||
disconnect(): void {
|
disconnect(): void {
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
import { Data, Enriched } from '@app/types';
|
import { Data } from '@app/types';
|
||||||
import type { IRoomResponse } from '@app/websocket';
|
import type { IRoomResponse, WebSocketRoomResponseOverrides } from '@app/websocket';
|
||||||
import { RoomsDispatch } from '@app/store';
|
import { RoomsDispatch } from '@app/store';
|
||||||
|
|
||||||
export class RoomResponseImpl implements IRoomResponse {
|
type Message = WebSocketRoomResponseOverrides['Event_RoomSay'];
|
||||||
|
|
||||||
|
export class RoomResponseImpl implements IRoomResponse<WebSocketRoomResponseOverrides> {
|
||||||
clearStore(): void {
|
clearStore(): void {
|
||||||
RoomsDispatch.clearStore();
|
RoomsDispatch.clearStore();
|
||||||
}
|
}
|
||||||
|
|
@ -23,7 +25,7 @@ export class RoomResponseImpl implements IRoomResponse {
|
||||||
RoomsDispatch.updateGames(roomId, gameList);
|
RoomsDispatch.updateGames(roomId, gameList);
|
||||||
}
|
}
|
||||||
|
|
||||||
addMessage(roomId: number, message: Enriched.Message): void {
|
addMessage(roomId: number, message: Message): void {
|
||||||
RoomsDispatch.addMessage(roomId, message);
|
RoomsDispatch.addMessage(roomId, message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,12 @@
|
||||||
import { App, Data, Enriched } from '@app/types';
|
import { Data } from '@app/types';
|
||||||
import type { ISessionResponse } from '@app/websocket';
|
import type { ISessionResponse, WebSocketSessionResponseOverrides } from '@app/websocket';
|
||||||
|
import { StatusEnum } from '@app/websocket';
|
||||||
import { GameDispatch, RoomsDispatch, ServerDispatch } from '@app/store';
|
import { GameDispatch, RoomsDispatch, ServerDispatch } from '@app/store';
|
||||||
|
|
||||||
export class SessionResponseImpl implements ISessionResponse {
|
type LoginSuccess = WebSocketSessionResponseOverrides['Response_Login'];
|
||||||
|
type PendingActivation = WebSocketSessionResponseOverrides['Response'];
|
||||||
|
|
||||||
|
export class SessionResponseImpl implements ISessionResponse<WebSocketSessionResponseOverrides> {
|
||||||
initialized(): void {
|
initialized(): void {
|
||||||
ServerDispatch.initialized();
|
ServerDispatch.initialized();
|
||||||
}
|
}
|
||||||
|
|
@ -15,7 +19,7 @@ export class SessionResponseImpl implements ISessionResponse {
|
||||||
ServerDispatch.clearStore();
|
ServerDispatch.clearStore();
|
||||||
}
|
}
|
||||||
|
|
||||||
loginSuccessful(options: Enriched.LoginSuccessContext): void {
|
loginSuccessful(options: LoginSuccess): void {
|
||||||
ServerDispatch.loginSuccessful(options);
|
ServerDispatch.loginSuccessful(options);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -63,8 +67,8 @@ export class SessionResponseImpl implements ISessionResponse {
|
||||||
ServerDispatch.updateInfo(name, version);
|
ServerDispatch.updateInfo(name, version);
|
||||||
}
|
}
|
||||||
|
|
||||||
updateStatus(state: App.StatusEnum, description: string): void {
|
updateStatus(state: StatusEnum, description: string): void {
|
||||||
if (state === App.StatusEnum.DISCONNECTED) {
|
if (state === StatusEnum.DISCONNECTED) {
|
||||||
GameDispatch.clearStore();
|
GameDispatch.clearStore();
|
||||||
RoomsDispatch.clearStore();
|
RoomsDispatch.clearStore();
|
||||||
ServerDispatch.clearStore();
|
ServerDispatch.clearStore();
|
||||||
|
|
@ -92,7 +96,7 @@ export class SessionResponseImpl implements ISessionResponse {
|
||||||
ServerDispatch.serverMessage(message);
|
ServerDispatch.serverMessage(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
accountAwaitingActivation(options: Enriched.PendingActivationContext): void {
|
accountAwaitingActivation(options: PendingActivation): void {
|
||||||
ServerDispatch.accountAwaitingActivation(options);
|
ServerDispatch.accountAwaitingActivation(options);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ import AddIcon from '@mui/icons-material/Add';
|
||||||
import EditRoundedIcon from '@mui/icons-material/Edit';
|
import EditRoundedIcon from '@mui/icons-material/Edit';
|
||||||
import ErrorOutlinedIcon from '@mui/icons-material/ErrorOutlined';
|
import ErrorOutlinedIcon from '@mui/icons-material/ErrorOutlined';
|
||||||
|
|
||||||
import { request } from '@app/api';
|
import { useWebClient } from '@app/hooks';
|
||||||
import { KnownHostDialog } from '@app/dialogs';
|
import { KnownHostDialog } from '@app/dialogs';
|
||||||
import { useReduxEffect } from '@app/hooks';
|
import { useReduxEffect } from '@app/hooks';
|
||||||
import { HostDTO } from '@app/services';
|
import { HostDTO } from '@app/services';
|
||||||
|
|
@ -64,6 +64,7 @@ const KnownHosts = (props) => {
|
||||||
const { touched, error, warning } = meta;
|
const { touched, error, warning } = meta;
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const webClient = useWebClient();
|
||||||
|
|
||||||
const [hostsState, setHostsState] = useState({
|
const [hostsState, setHostsState] = useState({
|
||||||
hosts: [],
|
hosts: [],
|
||||||
|
|
@ -197,7 +198,7 @@ const KnownHosts = (props) => {
|
||||||
setTestingConnection(TestConnection.TESTING);
|
setTestingConnection(TestConnection.TESTING);
|
||||||
|
|
||||||
const options = { ...App.getHostPort(hostsState.selectedHost) };
|
const options = { ...App.getHostPort(hostsState.selectedHost) };
|
||||||
request.authentication.testConnection(options);
|
webClient.request.authentication.testConnection(options);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
// eslint-disable-next-line
|
|
||||||
import React, { useEffect, useMemo, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { NavLink, generatePath } from 'react-router-dom';
|
import { NavLink, generatePath } from 'react-router-dom';
|
||||||
|
|
||||||
|
|
@ -20,7 +20,7 @@ const ParsedMessage = ({ message }) => {
|
||||||
const [messageChunks, setMessageChunks] = useState(null);
|
const [messageChunks, setMessageChunks] = useState(null);
|
||||||
const [name, setName] = useState(null);
|
const [name, setName] = useState(null);
|
||||||
|
|
||||||
useMemo(() => {
|
useEffect(() => {
|
||||||
const name = message.match(App.MESSAGE_SENDER_REGEX);
|
const name = message.match(App.MESSAGE_SENDER_REGEX);
|
||||||
|
|
||||||
if (name) {
|
if (name) {
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import Menu from '@mui/material/Menu';
|
||||||
import MenuItem from '@mui/material/MenuItem';
|
import MenuItem from '@mui/material/MenuItem';
|
||||||
|
|
||||||
import { Images } from '@app/images';
|
import { Images } from '@app/images';
|
||||||
import { request } from '@app/api';
|
import { useWebClient } from '@app/hooks';
|
||||||
import { ServerSelectors } from '@app/store';
|
import { ServerSelectors } from '@app/store';
|
||||||
import { App, Data } from '@app/types';
|
import { App, Data } from '@app/types';
|
||||||
import { useAppSelector } from '@app/store';
|
import { useAppSelector } from '@app/store';
|
||||||
|
|
@ -18,6 +18,7 @@ const UserDisplay = ({ user }: UserDisplayProps) => {
|
||||||
const buddyList = useAppSelector(state => ServerSelectors.getBuddyList(state));
|
const buddyList = useAppSelector(state => ServerSelectors.getBuddyList(state));
|
||||||
const ignoreList = useAppSelector(state => ServerSelectors.getIgnoreList(state));
|
const ignoreList = useAppSelector(state => ServerSelectors.getIgnoreList(state));
|
||||||
const [position, setPosition] = useState<{ x: number; y: number } | null>(null);
|
const [position, setPosition] = useState<{ x: number; y: number } | null>(null);
|
||||||
|
const webClient = useWebClient();
|
||||||
|
|
||||||
const { name, country } = user;
|
const { name, country } = user;
|
||||||
|
|
||||||
|
|
@ -32,19 +33,19 @@ const UserDisplay = ({ user }: UserDisplayProps) => {
|
||||||
const isIgnored = Boolean(ignoreList[user.name]);
|
const isIgnored = Boolean(ignoreList[user.name]);
|
||||||
|
|
||||||
const onAddBuddy = () => {
|
const onAddBuddy = () => {
|
||||||
request.session.addToBuddyList(user.name);
|
webClient.request.session.addToBuddyList(user.name);
|
||||||
handleClose();
|
handleClose();
|
||||||
};
|
};
|
||||||
const onRemoveBuddy = () => {
|
const onRemoveBuddy = () => {
|
||||||
request.session.removeFromBuddyList(user.name);
|
webClient.request.session.removeFromBuddyList(user.name);
|
||||||
handleClose();
|
handleClose();
|
||||||
};
|
};
|
||||||
const onAddIgnore = () => {
|
const onAddIgnore = () => {
|
||||||
request.session.addToIgnoreList(user.name);
|
webClient.request.session.addToIgnoreList(user.name);
|
||||||
handleClose();
|
handleClose();
|
||||||
};
|
};
|
||||||
const onRemoveIgnore = () => {
|
const onRemoveIgnore = () => {
|
||||||
request.session.removeFromIgnoreList(user.name);
|
webClient.request.session.removeFromIgnoreList(user.name);
|
||||||
handleClose();
|
handleClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import ListItemButton from '@mui/material/ListItemButton';
|
||||||
import Paper from '@mui/material/Paper';
|
import Paper from '@mui/material/Paper';
|
||||||
|
|
||||||
import { UserDisplay, VirtualList, AuthGuard, LanguageDropdown } from '@app/components';
|
import { UserDisplay, VirtualList, AuthGuard, LanguageDropdown } from '@app/components';
|
||||||
import { request } from '@app/api';
|
import { useWebClient } from '@app/hooks';
|
||||||
import { ServerSelectors } from '@app/store';
|
import { ServerSelectors } from '@app/store';
|
||||||
import Layout from '../Layout/Layout';
|
import Layout from '../Layout/Layout';
|
||||||
import { useAppSelector } from '@app/store';
|
import { useAppSelector } from '@app/store';
|
||||||
|
|
@ -23,17 +23,18 @@ const Account = () => {
|
||||||
const serverName = useAppSelector(state => ServerSelectors.getName(state));
|
const serverName = useAppSelector(state => ServerSelectors.getName(state));
|
||||||
const serverVersion = useAppSelector(state => ServerSelectors.getVersion(state));
|
const serverVersion = useAppSelector(state => ServerSelectors.getVersion(state));
|
||||||
const user = useAppSelector(state => ServerSelectors.getUser(state));
|
const user = useAppSelector(state => ServerSelectors.getUser(state));
|
||||||
|
const webClient = useWebClient();
|
||||||
const { country, realName, name, userLevel, accountageSecs, avatarBmp } = user || {};
|
const { country, realName, name, userLevel, accountageSecs, avatarBmp } = user || {};
|
||||||
let url = URL.createObjectURL(new Blob([avatarBmp as BlobPart], { 'type': 'image/png' }));
|
let url = URL.createObjectURL(new Blob([avatarBmp as BlobPart], { 'type': 'image/png' }));
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const handleAddToBuddies = ({ userName }) => {
|
const handleAddToBuddies = ({ userName }) => {
|
||||||
request.session.addToBuddyList(userName);
|
webClient.request.session.addToBuddyList(userName);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddToIgnore = ({ userName }) => {
|
const handleAddToIgnore = ({ userName }) => {
|
||||||
request.session.addToIgnoreList(userName);
|
webClient.request.session.addToIgnoreList(userName);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,8 @@ import CloseIcon from '@mui/icons-material/Close';
|
||||||
import MailOutlineRoundedIcon from '@mui/icons-material/MailOutlineRounded';
|
import MailOutlineRoundedIcon from '@mui/icons-material/MailOutlineRounded';
|
||||||
import MenuRoundedIcon from '@mui/icons-material/MenuRounded';
|
import MenuRoundedIcon from '@mui/icons-material/MenuRounded';
|
||||||
|
|
||||||
import { request } from '@app/api';
|
|
||||||
import { CardImportDialog } from '@app/dialogs';
|
import { CardImportDialog } from '@app/dialogs';
|
||||||
|
import { useWebClient } from '@app/hooks';
|
||||||
import { Images } from '@app/images';
|
import { Images } from '@app/images';
|
||||||
import { RoomsSelectors, ServerSelectors } from '@app/store';
|
import { RoomsSelectors, ServerSelectors } from '@app/store';
|
||||||
import { App } from '@app/types';
|
import { App } from '@app/types';
|
||||||
|
|
@ -28,6 +28,7 @@ const LeftNav = () => {
|
||||||
const isConnected = useAppSelector(ServerSelectors.getIsConnected);
|
const isConnected = useAppSelector(ServerSelectors.getIsConnected);
|
||||||
const isModerator = useAppSelector(ServerSelectors.getIsUserModerator);
|
const isModerator = useAppSelector(ServerSelectors.getIsUserModerator);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const webClient = useWebClient();
|
||||||
const [state, setState] = useState<LeftNavState>({
|
const [state, setState] = useState<LeftNavState>({
|
||||||
anchorEl: null,
|
anchorEl: null,
|
||||||
showCardImportDialog: false,
|
showCardImportDialog: false,
|
||||||
|
|
@ -66,7 +67,7 @@ const LeftNav = () => {
|
||||||
|
|
||||||
const leaveRoom = (event, roomId) => {
|
const leaveRoom = (event, roomId) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
request.rooms.leaveRoom(roomId);
|
webClient.request.rooms.leaveRoom(roomId);
|
||||||
};
|
};
|
||||||
|
|
||||||
const openImportCardWizard = () => {
|
const openImportCardWizard = () => {
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,10 @@ import Button from '@mui/material/Button';
|
||||||
import Paper from '@mui/material/Paper';
|
import Paper from '@mui/material/Paper';
|
||||||
import Typography from '@mui/material/Typography';
|
import Typography from '@mui/material/Typography';
|
||||||
|
|
||||||
import { request } from '@app/api';
|
|
||||||
import { RegistrationDialog, RequestPasswordResetDialog, ResetPasswordDialog, AccountActivationDialog } from '@app/dialogs';
|
import { RegistrationDialog, RequestPasswordResetDialog, ResetPasswordDialog, AccountActivationDialog } from '@app/dialogs';
|
||||||
import { LanguageDropdown } from '@app/components';
|
import { LanguageDropdown } from '@app/components';
|
||||||
import { LoginForm } from '@app/forms';
|
import { LoginForm } from '@app/forms';
|
||||||
import { useReduxEffect, useFireOnce } from '@app/hooks';
|
import { useReduxEffect, useFireOnce, useWebClient } from '@app/hooks';
|
||||||
import { Images } from '@app/images';
|
import { Images } from '@app/images';
|
||||||
import { HostDTO, serverProps } from '@app/services';
|
import { HostDTO, serverProps } from '@app/services';
|
||||||
import { App, Enriched } from '@app/types';
|
import { App, Enriched } from '@app/types';
|
||||||
|
|
@ -67,6 +66,7 @@ const Root = styled('div')(({ theme }) => ({
|
||||||
const Login = () => {
|
const Login = () => {
|
||||||
const description = useAppSelector(s => ServerSelectors.getDescription(s));
|
const description = useAppSelector(s => ServerSelectors.getDescription(s));
|
||||||
const isConnected = useAppSelector(ServerSelectors.getIsConnected);
|
const isConnected = useAppSelector(ServerSelectors.getIsConnected);
|
||||||
|
const webClient = useWebClient();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const [pendingActivationOptions, setPendingActivationOptions] = useState<Enriched.PendingActivationContext | null>(null);
|
const [pendingActivationOptions, setPendingActivationOptions] = useState<Enriched.PendingActivationContext | null>(null);
|
||||||
|
|
@ -134,7 +134,7 @@ const Login = () => {
|
||||||
options.hashedPassword = selectedHost.hashedPassword;
|
options.hashedPassword = selectedHost.hashedPassword;
|
||||||
}
|
}
|
||||||
|
|
||||||
request.authentication.login(options);
|
webClient.request.authentication.login(options);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const [submitButtonDisabled, resetSubmitButton, handleLogin] = useFireOnce(onSubmitLogin);
|
const [submitButtonDisabled, resetSubmitButton, handleLogin] = useFireOnce(onSubmitLogin);
|
||||||
|
|
@ -153,7 +153,7 @@ const Login = () => {
|
||||||
setRememberLogin(registerForm);
|
setRememberLogin(registerForm);
|
||||||
const { userName, password, email, country, realName, selectedHost } = registerForm;
|
const { userName, password, email, country, realName, selectedHost } = registerForm;
|
||||||
|
|
||||||
request.authentication.register({
|
webClient.request.authentication.register({
|
||||||
...App.getHostPort(selectedHost),
|
...App.getHostPort(selectedHost),
|
||||||
userName,
|
userName,
|
||||||
password,
|
password,
|
||||||
|
|
@ -167,7 +167,7 @@ const Login = () => {
|
||||||
if (!pendingActivationOptions) {
|
if (!pendingActivationOptions) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
request.authentication.activateAccount({
|
webClient.request.authentication.activateAccount({
|
||||||
host: pendingActivationOptions.host,
|
host: pendingActivationOptions.host,
|
||||||
port: pendingActivationOptions.port,
|
port: pendingActivationOptions.port,
|
||||||
userName: pendingActivationOptions.userName,
|
userName: pendingActivationOptions.userName,
|
||||||
|
|
@ -180,17 +180,17 @@ const Login = () => {
|
||||||
const { host, port } = App.getHostPort(selectedHost);
|
const { host, port } = App.getHostPort(selectedHost);
|
||||||
|
|
||||||
if (email) {
|
if (email) {
|
||||||
request.authentication.resetPasswordChallenge({ userName, email, host, port });
|
webClient.request.authentication.resetPasswordChallenge({ userName, email, host, port });
|
||||||
} else {
|
} else {
|
||||||
setUserToResetPassword(userName);
|
setUserToResetPassword(userName);
|
||||||
request.authentication.resetPasswordRequest({ userName, host, port });
|
webClient.request.authentication.resetPasswordRequest({ userName, host, port });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleResetPasswordDialogSubmit = ({ userName, token, newPassword, selectedHost }) => {
|
const handleResetPasswordDialogSubmit = ({ userName, token, newPassword, selectedHost }) => {
|
||||||
const { host, port } = App.getHostPort(selectedHost);
|
const { host, port } = App.getHostPort(selectedHost);
|
||||||
|
|
||||||
request.authentication.resetPassword({ userName, token, newPassword, host, port });
|
webClient.request.authentication.resetPassword({ userName, token, newPassword, host, port });
|
||||||
};
|
};
|
||||||
|
|
||||||
const skipTokenRequest = (userName) => {
|
const skipTokenRequest = (userName) => {
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
// eslint-disable-next-line
|
// eslint-disable-next-line
|
||||||
import React, { useEffect } from "react";
|
import React, { useEffect } from "react";
|
||||||
|
|
||||||
import { request } from '@app/api';
|
|
||||||
import { AuthGuard, ModGuard } from '@app/components';
|
import { AuthGuard, ModGuard } from '@app/components';
|
||||||
import { SearchForm } from '@app/forms';
|
import { SearchForm } from '@app/forms';
|
||||||
|
import { useWebClient } from '@app/hooks';
|
||||||
import { ServerDispatch, ServerSelectors } from '@app/store';
|
import { ServerDispatch, ServerSelectors } from '@app/store';
|
||||||
import { Data } from '@app/types';
|
import { Data } from '@app/types';
|
||||||
import { useAppSelector } from '@app/store';
|
import { useAppSelector } from '@app/store';
|
||||||
|
|
@ -13,6 +13,7 @@ import './Logs.css';
|
||||||
|
|
||||||
const Logs = () => {
|
const Logs = () => {
|
||||||
const logs = useAppSelector(state => ServerSelectors.getLogs(state));
|
const logs = useAppSelector(state => ServerSelectors.getLogs(state));
|
||||||
|
const webClient = useWebClient();
|
||||||
const MAXIMUM_RESULTS = 1000;
|
const MAXIMUM_RESULTS = 1000;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -51,7 +52,7 @@ const Logs = () => {
|
||||||
trimmedFields.maximumResults = MAXIMUM_RESULTS;
|
trimmedFields.maximumResults = MAXIMUM_RESULTS;
|
||||||
|
|
||||||
if (required.length) {
|
if (required.length) {
|
||||||
request.moderator.viewLogHistory(trimmedFields);
|
webClient.request.moderator.viewLogHistory(trimmedFields);
|
||||||
} else {
|
} else {
|
||||||
// @TODO use yet-to-be-implemented banner/alert
|
// @TODO use yet-to-be-implemented banner/alert
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,8 @@ import { useNavigate, useParams, generatePath } from 'react-router-dom';
|
||||||
import ListItemButton from '@mui/material/ListItemButton';
|
import ListItemButton from '@mui/material/ListItemButton';
|
||||||
import Paper from '@mui/material/Paper';
|
import Paper from '@mui/material/Paper';
|
||||||
|
|
||||||
import { request } from '@app/api';
|
|
||||||
import { ScrollToBottomOnChanges, ThreePaneLayout, UserDisplay, VirtualList, AuthGuard } from '@app/components';
|
import { ScrollToBottomOnChanges, ThreePaneLayout, UserDisplay, VirtualList, AuthGuard } from '@app/components';
|
||||||
|
import { useWebClient } from '@app/hooks';
|
||||||
import { RoomsSelectors } from '@app/store';
|
import { RoomsSelectors } from '@app/store';
|
||||||
import { useAppSelector } from '@app/store';
|
import { useAppSelector } from '@app/store';
|
||||||
import { App } from '@app/types';
|
import { App } from '@app/types';
|
||||||
|
|
@ -29,6 +29,7 @@ const Room = () => {
|
||||||
const room = rooms[roomId];
|
const room = rooms[roomId];
|
||||||
const roomMessages = messages[roomId];
|
const roomMessages = messages[roomId];
|
||||||
const users = useAppSelector(state => RoomsSelectors.getSortedRoomUsers(state, roomId));
|
const users = useAppSelector(state => RoomsSelectors.getSortedRoomUsers(state, roomId));
|
||||||
|
const webClient = useWebClient();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!joined.find(r => r.info.roomId === roomId)) {
|
if (!joined.find(r => r.info.roomId === roomId)) {
|
||||||
|
|
@ -38,7 +39,7 @@ const Room = () => {
|
||||||
|
|
||||||
const handleRoomSay = ({ message }) => {
|
const handleRoomSay = ({ message }) => {
|
||||||
if (message) {
|
if (message) {
|
||||||
request.rooms.roomSay(roomId, message);
|
webClient.request.rooms.roomSay(roomId, message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,20 +9,20 @@ import TableCell from '@mui/material/TableCell';
|
||||||
import TableHead from '@mui/material/TableHead';
|
import TableHead from '@mui/material/TableHead';
|
||||||
import TableRow from '@mui/material/TableRow';
|
import TableRow from '@mui/material/TableRow';
|
||||||
|
|
||||||
|
import { useWebClient } from '@app/hooks';
|
||||||
import { request } from '@app/api';
|
|
||||||
import { App } from '@app/types';
|
import { App } from '@app/types';
|
||||||
|
|
||||||
import './Rooms.css';
|
import './Rooms.css';
|
||||||
|
|
||||||
const Rooms = ({ rooms, joinedRooms }) => {
|
const Rooms = ({ rooms, joinedRooms }) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const webClient = useWebClient();
|
||||||
|
|
||||||
function onClick(roomId) {
|
function onClick(roomId) {
|
||||||
if (joinedRooms.find(room => room.info.roomId === roomId)) {
|
if (joinedRooms.find(room => room.info.roomId === roomId)) {
|
||||||
navigate(generatePath(App.RouteEnum.ROOM, { roomId }));
|
navigate(generatePath(App.RouteEnum.ROOM, { roomId }));
|
||||||
} else {
|
} else {
|
||||||
request.rooms.joinRoom(roomId);
|
webClient.request.rooms.joinRoom(roomId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,3 +3,4 @@ export * from './useFireOnce';
|
||||||
export * from './useDebounce';
|
export * from './useDebounce';
|
||||||
export * from './useLocaleSort';
|
export * from './useLocaleSort';
|
||||||
export * from './useReduxEffect';
|
export * from './useReduxEffect';
|
||||||
|
export * from './useWebClient';
|
||||||
|
|
|
||||||
19
webclient/src/hooks/useWebClient.tsx
Normal file
19
webclient/src/hooks/useWebClient.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
import { createContext, useContext, useState, ReactNode } from 'react';
|
||||||
|
import { WebClient } from '@app/websocket';
|
||||||
|
import { createWebClientRequest, createWebClientResponse } from '@app/api';
|
||||||
|
|
||||||
|
const WebClientContext = createContext<WebClient | null>(null);
|
||||||
|
|
||||||
|
export function WebClientProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [client] = useState(() => new WebClient(createWebClientRequest(), createWebClientResponse()));
|
||||||
|
|
||||||
|
return <WebClientContext value={client}>{children}</WebClientContext>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWebClient(): WebClient {
|
||||||
|
const client = useContext(WebClientContext);
|
||||||
|
if (!client) {
|
||||||
|
throw new Error('useWebClient must be used within a WebClientProvider');
|
||||||
|
}
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
@ -2,34 +2,21 @@
|
||||||
// creates the Redux store or connects to Redux DevTools.
|
// creates the Redux store or connects to Redux DevTools.
|
||||||
import './polyfills';
|
import './polyfills';
|
||||||
|
|
||||||
import { StrictMode, useRef } from 'react';
|
import { StrictMode } from 'react';
|
||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
import { StyledEngineProvider } from '@mui/material';
|
import { StyledEngineProvider } from '@mui/material';
|
||||||
import { ThemeProvider } from '@mui/material/styles';
|
import { ThemeProvider } from '@mui/material/styles';
|
||||||
|
|
||||||
import { WebClient } from '@app/websocket';
|
import { WebClientProvider } from '@app/hooks';
|
||||||
import { createWebClientResponse, createWebClientRequest } from '@app/api';
|
|
||||||
import { AppShell } from '@app/containers';
|
import { AppShell } from '@app/containers';
|
||||||
import { materialTheme } from './material-theme';
|
import { materialTheme } from './material-theme';
|
||||||
|
|
||||||
import './i18n';
|
import './i18n';
|
||||||
import './index.css';
|
import './index.css';
|
||||||
|
|
||||||
function initWebClient() {
|
|
||||||
const initialized = useRef(false);
|
|
||||||
|
|
||||||
if (!initialized.current) {
|
|
||||||
initialized.current = true;
|
|
||||||
new WebClient(createWebClientResponse(), createWebClientRequest());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const AppWithMaterialTheme = () => {
|
const AppWithMaterialTheme = () => {
|
||||||
// Instantiate the WebClient singleton before any container renders or any
|
|
||||||
// hook touches WebClient.instance.
|
|
||||||
initWebClient();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<WebClientProvider>
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<StyledEngineProvider injectFirst>
|
<StyledEngineProvider injectFirst>
|
||||||
<ThemeProvider theme={materialTheme}>
|
<ThemeProvider theme={materialTheme}>
|
||||||
|
|
@ -37,6 +24,7 @@ const AppWithMaterialTheme = () => {
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</StyledEngineProvider>
|
</StyledEngineProvider>
|
||||||
</StrictMode>
|
</StrictMode>
|
||||||
|
</WebClientProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -121,12 +121,12 @@ describe('2B: Game state & player management', () => {
|
||||||
const state = makeState();
|
const state = makeState();
|
||||||
const result = gamesReducer(state, Actions.gameStateChanged({
|
const result = gamesReducer(state, Actions.gameStateChanged({
|
||||||
gameId: 1,
|
gameId: 1,
|
||||||
data: {
|
data: create(Data.Event_GameStateChangedSchema, {
|
||||||
gameStarted: true,
|
gameStarted: true,
|
||||||
activePlayerId: 3,
|
activePlayerId: 3,
|
||||||
activePhase: 2,
|
activePhase: 2,
|
||||||
secondsElapsed: 60,
|
secondsElapsed: 60,
|
||||||
},
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
expect(result.games[1].started).toBe(true);
|
expect(result.games[1].started).toBe(true);
|
||||||
|
|
@ -394,7 +394,7 @@ describe('2C: CARD_MOVED', () => {
|
||||||
expect(moved.providerId).toBe('new-prov');
|
expect(moved.providerId).toBe('new-prov');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('CARD_MOVED → returns newState (card removed from source) when targetZone does not exist on player', () => {
|
it('CARD_MOVED → no-ops when targetZone does not exist on player', () => {
|
||||||
const { state } = stateWithCard();
|
const { state } = stateWithCard();
|
||||||
const result = gamesReducer(state, Actions.cardMoved({
|
const result = gamesReducer(state, Actions.cardMoved({
|
||||||
gameId: 1,
|
gameId: 1,
|
||||||
|
|
@ -414,7 +414,7 @@ describe('2C: CARD_MOVED', () => {
|
||||||
newCardProviderId: '',
|
newCardProviderId: '',
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
expect(cardsIn(result, 1, 1, 'hand')).toHaveLength(0);
|
expect(cardsIn(result, 1, 1, 'hand')).toHaveLength(1);
|
||||||
expect(result.games[1].players[1].zones['nonexistent']).toBeUndefined();
|
expect(result.games[1].players[1].zones['nonexistent']).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -850,7 +850,9 @@ describe('2I: Zone operations', () => {
|
||||||
const result = gamesReducer(state, Actions.zonePropertiesChanged({
|
const result = gamesReducer(state, Actions.zonePropertiesChanged({
|
||||||
gameId: 1,
|
gameId: 1,
|
||||||
playerId: 1,
|
playerId: 1,
|
||||||
data: { zoneName: 'hand', alwaysRevealTopCard: true, alwaysLookAtTopCard: true },
|
data: create(Data.Event_ChangeZonePropertiesSchema, {
|
||||||
|
zoneName: 'hand', alwaysRevealTopCard: true, alwaysLookAtTopCard: true,
|
||||||
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const zone = result.games[1].players[1].zones['hand'];
|
const zone = result.games[1].players[1].zones['hand'];
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||||
import { Data, Enriched } from '@app/types';
|
import { Data, Enriched } from '@app/types';
|
||||||
import { create } from '@bufbuild/protobuf';
|
import { create, isFieldSet } from '@bufbuild/protobuf';
|
||||||
import { GamesState } from './game.interfaces';
|
import { GamesState } from './game.interfaces';
|
||||||
|
|
||||||
export const MAX_GAME_MESSAGES = 1000;
|
export const MAX_GAME_MESSAGES = 1000;
|
||||||
|
|
@ -129,16 +129,16 @@ export const gamesSlice = createSlice({
|
||||||
if (data.playerList?.length > 0) {
|
if (data.playerList?.length > 0) {
|
||||||
game.players = normalizePlayers(data.playerList);
|
game.players = normalizePlayers(data.playerList);
|
||||||
}
|
}
|
||||||
if (data.gameStarted !== undefined && data.gameStarted !== null) {
|
if (isFieldSet(data, Data.Event_GameStateChangedSchema.field.gameStarted)) {
|
||||||
game.started = data.gameStarted;
|
game.started = data.gameStarted;
|
||||||
}
|
}
|
||||||
if (data.activePlayerId !== undefined && data.activePlayerId !== null) {
|
if (isFieldSet(data, Data.Event_GameStateChangedSchema.field.activePlayerId)) {
|
||||||
game.activePlayerId = data.activePlayerId;
|
game.activePlayerId = data.activePlayerId;
|
||||||
}
|
}
|
||||||
if (data.activePhase !== undefined && data.activePhase !== null) {
|
if (isFieldSet(data, Data.Event_GameStateChangedSchema.field.activePhase)) {
|
||||||
game.activePhase = data.activePhase;
|
game.activePhase = data.activePhase;
|
||||||
}
|
}
|
||||||
if (data.secondsElapsed !== undefined) {
|
if (isFieldSet(data, Data.Event_GameStateChangedSchema.field.secondsElapsed)) {
|
||||||
game.secondsElapsed = data.secondsElapsed;
|
game.secondsElapsed = data.secondsElapsed;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -201,6 +201,12 @@ export const gamesSlice = createSlice({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const targetPlayer = game.players[targetPlayerId];
|
||||||
|
const targetZoneEntry = targetPlayer?.zones[targetZone];
|
||||||
|
if (!targetPlayer || !targetZoneEntry) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let resolvedCardId = -1;
|
let resolvedCardId = -1;
|
||||||
if (cardId >= 0) {
|
if (cardId >= 0) {
|
||||||
resolvedCardId = cardId;
|
resolvedCardId = cardId;
|
||||||
|
|
@ -228,12 +234,6 @@ export const gamesSlice = createSlice({
|
||||||
}
|
}
|
||||||
: buildEmptyCard(effectiveNewId, cardName, x, y, faceDown, newCardProviderId ?? '');
|
: buildEmptyCard(effectiveNewId, cardName, x, y, faceDown, newCardProviderId ?? '');
|
||||||
|
|
||||||
const targetPlayer = game.players[targetPlayerId];
|
|
||||||
const targetZoneEntry = targetPlayer?.zones[targetZone];
|
|
||||||
if (!targetPlayer || !targetZoneEntry) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
targetZoneEntry.order.push(movedCard.id);
|
targetZoneEntry.order.push(movedCard.id);
|
||||||
targetZoneEntry.byId[movedCard.id] = movedCard;
|
targetZoneEntry.byId[movedCard.id] = movedCard;
|
||||||
targetZoneEntry.cardCount++;
|
targetZoneEntry.cardCount++;
|
||||||
|
|
@ -432,10 +432,10 @@ export const gamesSlice = createSlice({
|
||||||
if (!zone) {
|
if (!zone) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (data.alwaysRevealTopCard !== undefined && data.alwaysRevealTopCard !== null) {
|
if (isFieldSet(data, Data.Event_ChangeZonePropertiesSchema.field.alwaysRevealTopCard)) {
|
||||||
zone.alwaysRevealTopCard = data.alwaysRevealTopCard;
|
zone.alwaysRevealTopCard = data.alwaysRevealTopCard;
|
||||||
}
|
}
|
||||||
if (data.alwaysLookAtTopCard !== undefined && data.alwaysLookAtTopCard !== null) {
|
if (isFieldSet(data, Data.Event_ChangeZonePropertiesSchema.field.alwaysLookAtTopCard)) {
|
||||||
zone.alwaysLookAtTopCard = data.alwaysLookAtTopCard;
|
zone.alwaysLookAtTopCard = data.alwaysLookAtTopCard;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -114,12 +114,12 @@ describe('LEAVE_ROOM', () => {
|
||||||
// ── ADD_MESSAGE ───────────────────────────────────────────────────────────────
|
// ── ADD_MESSAGE ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe('ADD_MESSAGE', () => {
|
describe('ADD_MESSAGE', () => {
|
||||||
it('appends message with timeReceived set', () => {
|
it('appends message preserving the timeReceived from the event handler', () => {
|
||||||
const state = makeRoomsState({ messages: { 1: [] } });
|
const state = makeRoomsState({ messages: { 1: [] } });
|
||||||
const message = makeMessage({ message: 'hello', timeReceived: 0 });
|
const message = makeMessage({ message: 'hello', timeReceived: 1700000000000 });
|
||||||
const result = roomsReducer(state, Actions.addMessage({ roomId: 1, message }));
|
const result = roomsReducer(state, Actions.addMessage({ roomId: 1, message }));
|
||||||
expect(result.messages[1]).toHaveLength(1);
|
expect(result.messages[1]).toHaveLength(1);
|
||||||
expect(result.messages[1][0].timeReceived).toBeGreaterThan(0);
|
expect(result.messages[1][0].timeReceived).toBe(1700000000000);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('creates message list for roomId when none exists', () => {
|
it('creates message list for roomId when none exists', () => {
|
||||||
|
|
|
||||||
|
|
@ -69,14 +69,21 @@ export const roomsSlice = createSlice({
|
||||||
const { roomId } = action.payload;
|
const { roomId } = action.payload;
|
||||||
|
|
||||||
delete state.joinedRoomIds[roomId];
|
delete state.joinedRoomIds[roomId];
|
||||||
|
delete state.joinedGameIds[roomId];
|
||||||
delete state.messages[roomId];
|
delete state.messages[roomId];
|
||||||
|
|
||||||
|
const room = state.rooms[roomId];
|
||||||
|
if (room) {
|
||||||
|
room.games = {};
|
||||||
|
room.users = {};
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
addMessage: (state, action: PayloadAction<{ roomId: number; message: Enriched.Message }>) => {
|
addMessage: (state, action: PayloadAction<{ roomId: number; message: Enriched.Message }>) => {
|
||||||
const { roomId, message } = action.payload;
|
const { roomId, message } = action.payload;
|
||||||
|
|
||||||
const existing = state.messages[roomId] ?? [];
|
const existing = state.messages[roomId] ?? [];
|
||||||
const normalized = normalizeUserMessage({ ...message, timeReceived: Date.now() });
|
const normalized = normalizeUserMessage(message);
|
||||||
const next =
|
const next =
|
||||||
existing.length >= MAX_ROOM_MESSAGES
|
existing.length >= MAX_ROOM_MESSAGES
|
||||||
? [...existing.slice(existing.length - MAX_ROOM_MESSAGES + 1), normalized]
|
? [...existing.slice(existing.length - MAX_ROOM_MESSAGES + 1), normalized]
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { Selectors } from './rooms.selectors';
|
import { Selectors } from './rooms.selectors';
|
||||||
import { RoomsState } from './rooms.interfaces';
|
import { RoomsState } from './rooms.interfaces';
|
||||||
import { makeGame, makeMessage, makeRoom, makeRoomsState, makeUser } from './__mocks__/rooms-fixtures';
|
import { makeGame, makeMessage, makeRoom, makeRoomsState, makeUser } from './__mocks__/rooms-fixtures';
|
||||||
|
import { App } from '@app/types';
|
||||||
|
|
||||||
function rootState(rooms: RoomsState) {
|
function rootState(rooms: RoomsState) {
|
||||||
return { rooms };
|
return { rooms };
|
||||||
|
|
@ -111,13 +112,23 @@ describe('Selectors', () => {
|
||||||
expect(Selectors.getRoomUsers(rootState(state), 1)).toBe(room.users);
|
expect(Selectors.getRoomUsers(rootState(state), 1)).toBe(room.users);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('getSortedRoomGames → returns sorted array view of games map', () => {
|
it('getSortedRoomGames → returns games sorted by the active sort config', () => {
|
||||||
const game1 = makeGame({ gameId: 1, description: 'beta' });
|
const game1 = makeGame({ gameId: 1, description: 'Beta' });
|
||||||
const game2 = makeGame({ gameId: 2, description: 'alpha' });
|
const game2 = makeGame({ gameId: 2, description: 'Alpha' });
|
||||||
const room = makeRoom({ roomId: 1, games: { 1: game1, 2: game2 } });
|
const room = makeRoom({ roomId: 1, games: { 1: game1, 2: game2 } });
|
||||||
const state = makeRoomsState({ rooms: { 1: room } });
|
const state = makeRoomsState({
|
||||||
|
rooms: { 1: room },
|
||||||
|
sortGamesBy: { field: 'info.description' as App.GameSortField, order: App.SortDirection.ASC },
|
||||||
|
});
|
||||||
const result = Selectors.getSortedRoomGames(rootState(state), 1);
|
const result = Selectors.getSortedRoomGames(rootState(state), 1);
|
||||||
expect(result).toHaveLength(2);
|
expect(result).toHaveLength(2);
|
||||||
|
expect(result[0].info.description).toBe('Alpha');
|
||||||
|
expect(result[1].info.description).toBe('Beta');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getSortedRoomGames → returns EMPTY_GAMES for unknown roomId', () => {
|
||||||
|
const state = makeRoomsState({ rooms: {} });
|
||||||
|
expect(Selectors.getSortedRoomGames(rootState(state), 999)).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('getSortedRoomUsers → returns sorted user array sorted by name', () => {
|
it('getSortedRoomUsers → returns sorted user array sorted by name', () => {
|
||||||
|
|
@ -129,4 +140,40 @@ describe('Selectors', () => {
|
||||||
expect(result[0].name).toBe('Alice');
|
expect(result[0].name).toBe('Alice');
|
||||||
expect(result[1].name).toBe('Zane');
|
expect(result[1].name).toBe('Zane');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('getSortedRoomUsers → returns EMPTY_USERS for unknown roomId', () => {
|
||||||
|
const state = makeRoomsState({ rooms: {} });
|
||||||
|
expect(Selectors.getSortedRoomUsers(rootState(state), 999)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── createSelector reference stability ──────────────────────────────
|
||||||
|
|
||||||
|
it('getSortedRoomGames → returns same array reference for identical state', () => {
|
||||||
|
const game = makeGame({ gameId: 1 });
|
||||||
|
const room = makeRoom({ roomId: 1, games: { 1: game } });
|
||||||
|
const state = makeRoomsState({ rooms: { 1: room } });
|
||||||
|
const root = rootState(state);
|
||||||
|
const a = Selectors.getSortedRoomGames(root, 1);
|
||||||
|
const b = Selectors.getSortedRoomGames(root, 1);
|
||||||
|
expect(a).toBe(b);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getSortedRoomUsers → returns same array reference for identical state', () => {
|
||||||
|
const user = makeUser({ name: 'Alice' });
|
||||||
|
const room = makeRoom({ roomId: 1, users: { Alice: user } });
|
||||||
|
const state = makeRoomsState({ rooms: { 1: room } });
|
||||||
|
const root = rootState(state);
|
||||||
|
const a = Selectors.getSortedRoomUsers(root, 1);
|
||||||
|
const b = Selectors.getSortedRoomUsers(root, 1);
|
||||||
|
expect(a).toBe(b);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getJoinedRooms → returns same array reference for identical state', () => {
|
||||||
|
const room = makeRoom({ roomId: 1 });
|
||||||
|
const state = makeRoomsState({ rooms: { 1: room }, joinedRoomIds: { 1: true } });
|
||||||
|
const root = rootState(state);
|
||||||
|
const a = Selectors.getJoinedRooms(root);
|
||||||
|
const b = Selectors.getJoinedRooms(root);
|
||||||
|
expect(a).toBe(b);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -31,14 +31,18 @@ export const Selectors = {
|
||||||
* Reads from the room's normalized `games` map — fixes the pre-existing
|
* Reads from the room's normalized `games` map — fixes the pre-existing
|
||||||
* bug where this selector read from a never-populated top-level `games` field.
|
* bug where this selector read from a never-populated top-level `games` field.
|
||||||
*/
|
*/
|
||||||
getJoinedGames: (state: State, roomId: number): Enriched.Game[] => {
|
getJoinedGames: createSelector(
|
||||||
const room = state.rooms.rooms[roomId];
|
[
|
||||||
const joined = state.rooms.joinedGameIds[roomId];
|
(state: State, roomId: number) => state.rooms.rooms[roomId]?.games,
|
||||||
if (!room || !joined) {
|
(state: State, roomId: number) => state.rooms.joinedGameIds[roomId],
|
||||||
|
],
|
||||||
|
(games, joined): Enriched.Game[] => {
|
||||||
|
if (!games || !joined) {
|
||||||
return EMPTY_GAMES;
|
return EMPTY_GAMES;
|
||||||
}
|
}
|
||||||
return Object.values(room.games).filter(game => joined[game.info.gameId]);
|
return Object.values(games).filter(game => joined[game.info.gameId]);
|
||||||
},
|
}
|
||||||
|
),
|
||||||
|
|
||||||
getRoomMessages: (state: State, roomId: number) => state.rooms.messages[roomId],
|
getRoomMessages: (state: State, roomId: number) => state.rooms.messages[roomId],
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ export interface ServerState {
|
||||||
backendDecks: Data.Response_DeckList | null;
|
backendDecks: Data.Response_DeckList | null;
|
||||||
downloadedDeck: { deckId: number; deck: string } | null;
|
downloadedDeck: { deckId: number; deck: string } | null;
|
||||||
downloadedReplay: { replayId: number; replayData: Uint8Array } | null;
|
downloadedReplay: { replayId: number; replayData: Uint8Array } | null;
|
||||||
gamesOfUser: { [userName: string]: Enriched.Game[] };
|
gamesOfUser: { [userName: string]: { [gameId: number]: Enriched.Game } };
|
||||||
registrationError: string | null;
|
registrationError: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -648,29 +648,29 @@ describe('Deck Storage', () => {
|
||||||
// ── GAMES_OF_USER ─────────────────────────────────────────────────────────────
|
// ── GAMES_OF_USER ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe('GAMES_OF_USER', () => {
|
describe('GAMES_OF_USER', () => {
|
||||||
it('stores normalized games keyed by userName', () => {
|
it('stores normalized games keyed by userName and gameId', () => {
|
||||||
const response = create(Data.Response_GetGamesOfUserSchema, {
|
const response = create(Data.Response_GetGamesOfUserSchema, {
|
||||||
gameList: [create(Data.ServerInfo_GameSchema, { gameId: 5, description: '' })],
|
gameList: [create(Data.ServerInfo_GameSchema, { gameId: 5, description: '' })],
|
||||||
roomList: [],
|
roomList: [],
|
||||||
});
|
});
|
||||||
const state = makeServerState();
|
const state = makeServerState();
|
||||||
const result = serverReducer(state, Actions.gamesOfUser({ userName: 'alice', response }));
|
const result = serverReducer(state, Actions.gamesOfUser({ userName: 'alice', response }));
|
||||||
expect(result.gamesOfUser['alice']).toEqual([makeGame({ gameId: 5 })]);
|
expect(result.gamesOfUser['alice']).toEqual({ 5: makeGame({ gameId: 5 }) });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('overwrites previous games for same user', () => {
|
it('overwrites previous games for same user', () => {
|
||||||
const old = [makeGame({ gameId: 1 })];
|
const old = { 1: makeGame({ gameId: 1 }) };
|
||||||
const response = create(Data.Response_GetGamesOfUserSchema, {
|
const response = create(Data.Response_GetGamesOfUserSchema, {
|
||||||
gameList: [create(Data.ServerInfo_GameSchema, { gameId: 2, description: '' })],
|
gameList: [create(Data.ServerInfo_GameSchema, { gameId: 2, description: '' })],
|
||||||
roomList: [],
|
roomList: [],
|
||||||
});
|
});
|
||||||
const state = makeServerState({ gamesOfUser: { alice: old } });
|
const state = makeServerState({ gamesOfUser: { alice: old } });
|
||||||
const result = serverReducer(state, Actions.gamesOfUser({ userName: 'alice', response }));
|
const result = serverReducer(state, Actions.gamesOfUser({ userName: 'alice', response }));
|
||||||
expect(result.gamesOfUser['alice']).toEqual([makeGame({ gameId: 2 })]);
|
expect(result.gamesOfUser['alice']).toEqual({ 2: makeGame({ gameId: 2 }) });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not affect other users\' entries', () => {
|
it('does not affect other users\' entries', () => {
|
||||||
const bobGames = [makeGame({ gameId: 3 })];
|
const bobGames = { 3: makeGame({ gameId: 3 }) };
|
||||||
const response = create(Data.Response_GetGamesOfUserSchema, { gameList: [], roomList: [] });
|
const response = create(Data.Response_GetGamesOfUserSchema, { gameList: [], roomList: [] });
|
||||||
const state = makeServerState({ gamesOfUser: { bob: bobGames } });
|
const state = makeServerState({ gamesOfUser: { bob: bobGames } });
|
||||||
const result = serverReducer(state, Actions.gamesOfUser({ userName: 'alice', response }));
|
const result = serverReducer(state, Actions.gamesOfUser({ userName: 'alice', response }));
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||||
import { App, Data } from '@app/types';
|
import { App, Data, Enriched } from '@app/types';
|
||||||
import { create } from '@bufbuild/protobuf';
|
import { create } from '@bufbuild/protobuf';
|
||||||
|
|
||||||
import { normalizeBannedUserError, normalizeGameObject, normalizeGametypeMap, normalizeLogs } from '../common';
|
import { normalizeBannedUserError, normalizeGameObject, normalizeGametypeMap, normalizeLogs } from '../common';
|
||||||
|
|
@ -179,8 +179,10 @@ export const serverSlice = createSlice({
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
updateUser: (state, action: PayloadAction<{ user: Data.ServerInfo_User | Partial<Data.ServerInfo_User> }>) => {
|
updateUser: (state, action: PayloadAction<{ user: Partial<Data.ServerInfo_User> }>) => {
|
||||||
state.user = { ...state.user, ...action.payload.user } as Data.ServerInfo_User;
|
state.user = state.user
|
||||||
|
? { ...state.user, ...action.payload.user } as Data.ServerInfo_User
|
||||||
|
: action.payload.user as Data.ServerInfo_User;
|
||||||
},
|
},
|
||||||
|
|
||||||
updateUsers: (state, action: PayloadAction<{ users: Data.ServerInfo_User[] }>) => {
|
updateUsers: (state, action: PayloadAction<{ users: Data.ServerInfo_User[] }>) => {
|
||||||
|
|
@ -356,8 +358,12 @@ export const serverSlice = createSlice({
|
||||||
const gametypeMap = normalizeGametypeMap(
|
const gametypeMap = normalizeGametypeMap(
|
||||||
(response.roomList ?? []).flatMap(room => room.gametypeList ?? [])
|
(response.roomList ?? []).flatMap(room => room.gametypeList ?? [])
|
||||||
);
|
);
|
||||||
const normalizedGames = (response.gameList ?? []).map(g => normalizeGameObject(g, gametypeMap));
|
const games: { [gameId: number]: Enriched.Game } = {};
|
||||||
state.gamesOfUser[userName] = normalizedGames;
|
for (const g of response.gameList ?? []) {
|
||||||
|
const normalized = normalizeGameObject(g, gametypeMap);
|
||||||
|
games[normalized.info.gameId] = normalized;
|
||||||
|
}
|
||||||
|
state.gamesOfUser[userName] = games;
|
||||||
},
|
},
|
||||||
|
|
||||||
registrationFailed: (state, action: PayloadAction<{ reason: string; endTime?: number }>) => {
|
registrationFailed: (state, action: PayloadAction<{ reason: string; endTime?: number }>) => {
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import {
|
||||||
makeServerState,
|
makeServerState,
|
||||||
makeUser,
|
makeUser,
|
||||||
} from './__mocks__/server-fixtures';
|
} from './__mocks__/server-fixtures';
|
||||||
import { App } from '@app/types';
|
import { App, Data } from '@app/types';
|
||||||
|
|
||||||
function rootState(server: ServerState) {
|
function rootState(server: ServerState) {
|
||||||
return { server };
|
return { server };
|
||||||
|
|
@ -149,4 +149,86 @@ describe('Selectors', () => {
|
||||||
const state = makeServerState({ registrationError: 'bad input' });
|
const state = makeServerState({ registrationError: 'bad input' });
|
||||||
expect(Selectors.getRegistrationError(rootState(state))).toBe('bad input');
|
expect(Selectors.getRegistrationError(rootState(state))).toBe('bad input');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── derived selectors (createSelector) ──────────────────────────────
|
||||||
|
|
||||||
|
it('getIsConnected → true when state is LOGGED_IN', () => {
|
||||||
|
const state = makeServerState({ status: { connectionAttemptMade: true, state: App.StatusEnum.LOGGED_IN, description: null } });
|
||||||
|
expect(Selectors.getIsConnected(rootState(state))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getIsConnected → false when state is CONNECTED', () => {
|
||||||
|
const state = makeServerState({ status: { connectionAttemptMade: true, state: App.StatusEnum.CONNECTED, description: null } });
|
||||||
|
expect(Selectors.getIsConnected(rootState(state))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getIsConnected → false when state is DISCONNECTED', () => {
|
||||||
|
const state = makeServerState({ status: { connectionAttemptMade: false, state: App.StatusEnum.DISCONNECTED, description: null } });
|
||||||
|
expect(Selectors.getIsConnected(rootState(state))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getIsUserModerator → true when user has IsModerator flag', () => {
|
||||||
|
const Flag = Data.ServerInfo_User_UserLevelFlag;
|
||||||
|
const user = makeUser({ userLevel: Flag.IsUser | Flag.IsModerator });
|
||||||
|
const state = makeServerState({ user });
|
||||||
|
expect(Selectors.getIsUserModerator(rootState(state))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getIsUserModerator → false when user lacks IsModerator flag', () => {
|
||||||
|
const Flag = Data.ServerInfo_User_UserLevelFlag;
|
||||||
|
const user = makeUser({ userLevel: Flag.IsUser | Flag.IsRegistered });
|
||||||
|
const state = makeServerState({ user });
|
||||||
|
expect(Selectors.getIsUserModerator(rootState(state))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getIsUserModerator → false when user is null', () => {
|
||||||
|
const state = makeServerState({ user: null });
|
||||||
|
expect(Selectors.getIsUserModerator(rootState(state))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── createSelector reference stability ──────────────────────────────
|
||||||
|
|
||||||
|
it('getIsConnected → returns same value reference for identical state', () => {
|
||||||
|
const state = makeServerState({ status: { connectionAttemptMade: true, state: App.StatusEnum.LOGGED_IN, description: null } });
|
||||||
|
const root = rootState(state);
|
||||||
|
const a = Selectors.getIsConnected(root);
|
||||||
|
const b = Selectors.getIsConnected(root);
|
||||||
|
expect(a).toBe(b);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getSortedUsers → returns same array reference for identical state', () => {
|
||||||
|
const users = { Alice: makeUser({ name: 'Alice' }), Bob: makeUser({ name: 'Bob' }) };
|
||||||
|
const state = makeServerState({ users });
|
||||||
|
const root = rootState(state);
|
||||||
|
const a = Selectors.getSortedUsers(root);
|
||||||
|
const b = Selectors.getSortedUsers(root);
|
||||||
|
expect(a).toBe(b);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getSortedBuddyList → returns same array reference for identical state', () => {
|
||||||
|
const buddyList = { Alice: makeUser({ name: 'Alice' }) };
|
||||||
|
const state = makeServerState({ buddyList });
|
||||||
|
const root = rootState(state);
|
||||||
|
const a = Selectors.getSortedBuddyList(root);
|
||||||
|
const b = Selectors.getSortedBuddyList(root);
|
||||||
|
expect(a).toBe(b);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getSortedIgnoreList → returns same array reference for identical state', () => {
|
||||||
|
const ignoreList = { Troll: makeUser({ name: 'Troll' }) };
|
||||||
|
const state = makeServerState({ ignoreList });
|
||||||
|
const root = rootState(state);
|
||||||
|
const a = Selectors.getSortedIgnoreList(root);
|
||||||
|
const b = Selectors.getSortedIgnoreList(root);
|
||||||
|
expect(a).toBe(b);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getReplaysList → returns same array reference for identical state', () => {
|
||||||
|
const replays = { 1: makeReplayMatch({ gameId: 1 }) };
|
||||||
|
const state = makeServerState({ replays });
|
||||||
|
const root = rootState(state);
|
||||||
|
const a = Selectors.getReplaysList(root);
|
||||||
|
const b = Selectors.getReplaysList(root);
|
||||||
|
expect(a).toBe(b);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,6 @@ import type {
|
||||||
ServerInfo_User,
|
ServerInfo_User,
|
||||||
} from '@app/generated';
|
} from '@app/generated';
|
||||||
|
|
||||||
import { WebSocketConnectReason } from './server';
|
|
||||||
|
|
||||||
// ── Domain model types (composition: raw proto + client-side fields) ──────────
|
// ── Domain model types (composition: raw proto + client-side fields) ──────────
|
||||||
//
|
//
|
||||||
// `info` holds the proto snapshot verbatim. Normalized/client-only fields
|
// `info` holds the proto snapshot verbatim. Normalized/client-only fields
|
||||||
|
|
@ -133,84 +131,20 @@ export interface LogGroups {
|
||||||
chat: ServerInfo_ChatMessage[];
|
chat: ServerInfo_ChatMessage[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Connect options ───────────────────────────────────────────────────────────
|
// ── Connect options (re-exported from @app/websocket) ────────────────────────
|
||||||
// Each variant is the enriched input for one session flow: the network
|
// Source of truth lives in src/websocket/connectOptions.ts. Re-exported here
|
||||||
// transport fields (host/port) + the subset of proto Command_* fields the UI
|
// so UI code can use the Enriched.* namespace without importing @app/websocket.
|
||||||
// actually produces (user-entered credentials, tokens, email, etc.) + a
|
|
||||||
// `reason` discriminator so the websocket layer can route.
|
|
||||||
//
|
|
||||||
// Hand-written instead of `MessageInitShape<typeof Command_XSchema> & ...`
|
|
||||||
// because MessageInitShape is a `Message<T> | { initShape }` union which
|
|
||||||
// collapses to the Message branding when intersected, requiring `$typeName`
|
|
||||||
// on literals. Keep these in sync with the corresponding proto command by
|
|
||||||
// convention; fields here map 1:1 to Command_* members.
|
|
||||||
|
|
||||||
interface ConnectTransport {
|
export type {
|
||||||
host: string;
|
LoginConnectOptions,
|
||||||
port: string;
|
RegisterConnectOptions,
|
||||||
keepalive?: number;
|
ActivateConnectOptions,
|
||||||
autojoinrooms?: boolean;
|
PasswordResetRequestConnectOptions,
|
||||||
clientid?: string;
|
PasswordResetChallengeConnectOptions,
|
||||||
}
|
PasswordResetConnectOptions,
|
||||||
|
TestConnectionOptions,
|
||||||
export interface LoginConnectOptions extends ConnectTransport {
|
WebSocketConnectOptions,
|
||||||
reason: WebSocketConnectReason.LOGIN;
|
} from '@app/websocket';
|
||||||
userName: string;
|
|
||||||
password?: string;
|
|
||||||
hashedPassword?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RegisterConnectOptions extends ConnectTransport {
|
|
||||||
reason: WebSocketConnectReason.REGISTER;
|
|
||||||
userName: string;
|
|
||||||
password: string;
|
|
||||||
email: string;
|
|
||||||
country: string;
|
|
||||||
realName: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ActivateConnectOptions extends ConnectTransport {
|
|
||||||
reason: WebSocketConnectReason.ACTIVATE_ACCOUNT;
|
|
||||||
userName: string;
|
|
||||||
token: string;
|
|
||||||
/** Plaintext password carried through so post-activation auto-login can hash it. */
|
|
||||||
password?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PasswordResetRequestConnectOptions extends ConnectTransport {
|
|
||||||
reason: WebSocketConnectReason.PASSWORD_RESET_REQUEST;
|
|
||||||
userName: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PasswordResetChallengeConnectOptions extends ConnectTransport {
|
|
||||||
reason: WebSocketConnectReason.PASSWORD_RESET_CHALLENGE;
|
|
||||||
userName: string;
|
|
||||||
email: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PasswordResetConnectOptions extends ConnectTransport {
|
|
||||||
reason: WebSocketConnectReason.PASSWORD_RESET;
|
|
||||||
userName: string;
|
|
||||||
token: string;
|
|
||||||
newPassword: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Test connection has no proto command — it just opens and closes a socket to
|
|
||||||
* verify reachability.
|
|
||||||
*/
|
|
||||||
export interface TestConnectionOptions extends ConnectTransport {
|
|
||||||
reason: WebSocketConnectReason.TEST_CONNECTION;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type WebSocketConnectOptions =
|
|
||||||
| LoginConnectOptions
|
|
||||||
| RegisterConnectOptions
|
|
||||||
| ActivateConnectOptions
|
|
||||||
| PasswordResetRequestConnectOptions
|
|
||||||
| PasswordResetChallengeConnectOptions
|
|
||||||
| PasswordResetConnectOptions
|
|
||||||
| TestConnectionOptions;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Context preserved through the ACCOUNT_AWAITING_ACTIVATION signal so the
|
* Context preserved through the ACCOUNT_AWAITING_ACTIVATION signal so the
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,11 @@
|
||||||
|
export { StatusEnum, WebSocketConnectReason } from '@app/websocket';
|
||||||
|
import type { StatusEnum } from '@app/websocket';
|
||||||
|
|
||||||
export interface ServerStatus {
|
export interface ServerStatus {
|
||||||
status: StatusEnum;
|
status: StatusEnum;
|
||||||
description: string;
|
description: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum StatusEnum {
|
|
||||||
DISCONNECTED,
|
|
||||||
CONNECTING,
|
|
||||||
CONNECTED,
|
|
||||||
LOGGING_IN,
|
|
||||||
LOGGED_IN,
|
|
||||||
DISCONNECTING = 99
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum WebSocketConnectReason {
|
|
||||||
LOGIN,
|
|
||||||
REGISTER,
|
|
||||||
ACTIVATE_ACCOUNT,
|
|
||||||
PASSWORD_RESET_REQUEST,
|
|
||||||
PASSWORD_RESET_CHALLENGE,
|
|
||||||
PASSWORD_RESET,
|
|
||||||
TEST_CONNECTION,
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Host {
|
export class Host {
|
||||||
id?: number;
|
id?: number;
|
||||||
name: string;
|
name: string;
|
||||||
|
|
|
||||||
|
|
@ -17,8 +17,8 @@ vi.mock('./services/WebSocketService', () => ({
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('./services/ProtobufService', () => ({
|
vi.mock('./services/ProtobufService', () => ({
|
||||||
ProtobufService: vi.fn().mockImplementation(function ProtobufServiceImpl(options: SocketTransport) {
|
ProtobufService: vi.fn().mockImplementation(function ProtobufServiceImpl(transport: SocketTransport) {
|
||||||
captured.pbOptions = options;
|
captured.pbOptions = transport;
|
||||||
return {
|
return {
|
||||||
handleMessageEvent: vi.fn(),
|
handleMessageEvent: vi.fn(),
|
||||||
resetCommands: vi.fn(),
|
resetCommands: vi.fn(),
|
||||||
|
|
@ -26,20 +26,16 @@ vi.mock('./services/ProtobufService', () => ({
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('./commands/session', () => ({
|
|
||||||
ping: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { WebClient } from './WebClient';
|
import { WebClient } from './WebClient';
|
||||||
import { WebSocketService } from './services/WebSocketService';
|
import { WebSocketService } from './services/WebSocketService';
|
||||||
import { ProtobufService } from './services/ProtobufService';
|
import { ProtobufService } from './services/ProtobufService';
|
||||||
import { ping } from './commands/session';
|
import { StatusEnum } from './interfaces/StatusEnum';
|
||||||
import { App, Enriched } from '@app/types';
|
|
||||||
import { Subject } from 'rxjs';
|
import { Subject } from 'rxjs';
|
||||||
import { Mock } from 'vitest';
|
import { Mock } from 'vitest';
|
||||||
import { SocketTransport } from './services/ProtobufService';
|
import { SocketTransport } from './services/ProtobufService';
|
||||||
import { WebSocketServiceConfig } from './services/WebSocketService';
|
import { WebSocketServiceConfig } from './services/WebSocketService';
|
||||||
import type { IWebClientResponse, IWebClientRequest } from './interfaces';
|
import type { IWebClientResponse, IWebClientRequest } from './interfaces';
|
||||||
|
import type { ConnectTarget } from './interfaces/WebClientConfig';
|
||||||
import { installMockWebSocket } from './__mocks__/helpers';
|
import { installMockWebSocket } from './__mocks__/helpers';
|
||||||
|
|
||||||
function makeMockResponse(): IWebClientResponse {
|
function makeMockResponse(): IWebClientResponse {
|
||||||
|
|
@ -47,8 +43,11 @@ function makeMockResponse(): IWebClientResponse {
|
||||||
session: {
|
session: {
|
||||||
initialized: vi.fn(),
|
initialized: vi.fn(),
|
||||||
connectionAttempted: vi.fn(),
|
connectionAttempted: vi.fn(),
|
||||||
|
connectionFailed: vi.fn(),
|
||||||
clearStore: vi.fn(),
|
clearStore: vi.fn(),
|
||||||
updateStatus: vi.fn(),
|
updateStatus: vi.fn(),
|
||||||
|
testConnectionSuccessful: vi.fn(),
|
||||||
|
testConnectionFailed: vi.fn(),
|
||||||
},
|
},
|
||||||
room: { clearStore: vi.fn() },
|
room: { clearStore: vi.fn() },
|
||||||
game: { clearStore: vi.fn() },
|
game: { clearStore: vi.fn() },
|
||||||
|
|
@ -58,13 +57,7 @@ function makeMockResponse(): IWebClientResponse {
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeMockRequest(): IWebClientRequest {
|
function makeMockRequest(): IWebClientRequest {
|
||||||
return {
|
return {} as IWebClientRequest;
|
||||||
authentication: {},
|
|
||||||
session: {},
|
|
||||||
rooms: {},
|
|
||||||
admin: {},
|
|
||||||
moderator: {},
|
|
||||||
} as unknown as IWebClientRequest;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('WebClient', () => {
|
describe('WebClient', () => {
|
||||||
|
|
@ -74,11 +67,10 @@ describe('WebClient', () => {
|
||||||
let messageSubject: Subject<MessageEvent>;
|
let messageSubject: Subject<MessageEvent>;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// Reset the singleton so each test starts fresh.
|
|
||||||
(WebClient as unknown as { _instance: WebClient | null })._instance = null;
|
(WebClient as unknown as { _instance: WebClient | null })._instance = null;
|
||||||
|
|
||||||
(ProtobufService as Mock).mockImplementation(function ProtobufServiceImpl(options: SocketTransport) {
|
(ProtobufService as Mock).mockImplementation(function ProtobufServiceImpl(transport: SocketTransport) {
|
||||||
captured.pbOptions = options;
|
captured.pbOptions = transport;
|
||||||
return {
|
return {
|
||||||
handleMessageEvent: vi.fn(),
|
handleMessageEvent: vi.fn(),
|
||||||
resetCommands: vi.fn(),
|
resetCommands: vi.fn(),
|
||||||
|
|
@ -99,7 +91,7 @@ describe('WebClient', () => {
|
||||||
|
|
||||||
mockResponse = makeMockResponse();
|
mockResponse = makeMockResponse();
|
||||||
mockRequest = makeMockRequest();
|
mockRequest = makeMockRequest();
|
||||||
client = new WebClient(mockResponse, mockRequest);
|
client = new WebClient(mockRequest, mockResponse);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
|
@ -108,9 +100,9 @@ describe('WebClient', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('constructor', () => {
|
describe('constructor', () => {
|
||||||
it('stores the response and request on the instance', () => {
|
it('stores the request and response on the instance', () => {
|
||||||
expect(client.response).toBe(mockResponse);
|
|
||||||
expect(client.request).toBe(mockRequest);
|
expect(client.request).toBe(mockRequest);
|
||||||
|
expect(client.response).toBe(mockResponse);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('subscribes socket.message$ to protobuf.handleMessageEvent', () => {
|
it('subscribes socket.message$ to protobuf.handleMessageEvent', () => {
|
||||||
|
|
@ -128,7 +120,7 @@ describe('WebClient', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws when instantiated more than once', () => {
|
it('throws when instantiated more than once', () => {
|
||||||
expect(() => new WebClient(makeMockResponse(), makeMockRequest())).toThrow(/singleton/);
|
expect(() => new WebClient(makeMockRequest(), makeMockResponse())).toThrow(/singleton/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -141,16 +133,15 @@ describe('WebClient', () => {
|
||||||
|
|
||||||
describe('connect', () => {
|
describe('connect', () => {
|
||||||
it('calls response.session.connectionAttempted', () => {
|
it('calls response.session.connectionAttempted', () => {
|
||||||
const opts: Enriched.WebSocketConnectOptions = { host: 'h', port: '1', reason: App.WebSocketConnectReason.LOGIN, userName: 'u' };
|
const target: ConnectTarget = { host: 'h', port: '1' };
|
||||||
client.connect(opts);
|
client.connect(target);
|
||||||
expect(mockResponse.session.connectionAttempted).toHaveBeenCalled();
|
expect(mockResponse.session.connectionAttempted).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('stores options and calls socket.connect', () => {
|
it('calls socket.connect with target', () => {
|
||||||
const opts: Enriched.WebSocketConnectOptions = { host: 'h', port: '1', reason: App.WebSocketConnectReason.LOGIN, userName: 'u' };
|
const target: ConnectTarget = { host: 'h', port: '1' };
|
||||||
client.connect(opts);
|
client.connect(target);
|
||||||
expect(client.options).toBe(opts);
|
expect(client.socket.connect).toHaveBeenCalledWith(target);
|
||||||
expect(client.socket.connect).toHaveBeenCalledWith(opts);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -172,30 +163,28 @@ describe('WebClient', () => {
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
const opts: Enriched.WebSocketConnectOptions = { host: 'h', port: '1', reason: App.WebSocketConnectReason.LOGIN, userName: 'u' };
|
const target: ConnectTarget = { host: 'h', port: '1' };
|
||||||
|
|
||||||
it('creates a WebSocket with the correct URL', () => {
|
it('creates a WebSocket with the correct URL', () => {
|
||||||
client.testConnect(opts);
|
client.testConnect(target);
|
||||||
expect(MockWS).toHaveBeenCalledWith(expect.stringContaining('://h:1'));
|
expect(MockWS).toHaveBeenCalledWith(expect.stringContaining('://h:1'));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('calls testConnectionSuccessful and closes on open', () => {
|
it('calls testConnectionSuccessful and closes on open', () => {
|
||||||
(mockResponse.session as any).testConnectionSuccessful = vi.fn();
|
client.testConnect(target);
|
||||||
client.testConnect(opts);
|
|
||||||
wsMockInstance.onopen();
|
wsMockInstance.onopen();
|
||||||
expect((mockResponse.session as any).testConnectionSuccessful).toHaveBeenCalled();
|
expect(mockResponse.session.testConnectionSuccessful).toHaveBeenCalled();
|
||||||
expect(wsMockInstance.close).toHaveBeenCalled();
|
expect(wsMockInstance.close).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('calls testConnectionFailed on error', () => {
|
it('calls testConnectionFailed on error', () => {
|
||||||
(mockResponse.session as any).testConnectionFailed = vi.fn();
|
client.testConnect(target);
|
||||||
client.testConnect(opts);
|
|
||||||
wsMockInstance.onerror();
|
wsMockInstance.onerror();
|
||||||
expect((mockResponse.session as any).testConnectionFailed).toHaveBeenCalled();
|
expect(mockResponse.session.testConnectionFailed).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('closes socket after keepalive timeout', () => {
|
it('closes socket after keepalive timeout', () => {
|
||||||
client.testConnect(opts);
|
client.testConnect(target);
|
||||||
vi.advanceTimersByTime(5000);
|
vi.advanceTimersByTime(5000);
|
||||||
expect(wsMockInstance.close).toHaveBeenCalled();
|
expect(wsMockInstance.close).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
@ -210,32 +199,36 @@ describe('WebClient', () => {
|
||||||
|
|
||||||
describe('updateStatus', () => {
|
describe('updateStatus', () => {
|
||||||
it('sets the status', () => {
|
it('sets the status', () => {
|
||||||
client.updateStatus(App.StatusEnum.CONNECTED);
|
client.updateStatus(StatusEnum.CONNECTED);
|
||||||
expect(client.status).toBe(App.StatusEnum.CONNECTED);
|
expect(client.status).toBe(StatusEnum.CONNECTED);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('calls protobuf.resetCommands on DISCONNECTED', () => {
|
it('calls protobuf.resetCommands on DISCONNECTED', () => {
|
||||||
client.updateStatus(App.StatusEnum.DISCONNECTED);
|
client.updateStatus(StatusEnum.DISCONNECTED);
|
||||||
expect(client.protobuf.resetCommands).toHaveBeenCalled();
|
expect(client.protobuf.resetCommands).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not reset protobuf when status is not DISCONNECTED', () => {
|
it('does not reset protobuf when status is not DISCONNECTED', () => {
|
||||||
client.updateStatus(App.StatusEnum.CONNECTED);
|
client.updateStatus(StatusEnum.CONNECTED);
|
||||||
expect(client.protobuf.resetCommands).not.toHaveBeenCalled();
|
expect(client.protobuf.resetCommands).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('constructor closures', () => {
|
describe('constructor closures', () => {
|
||||||
it('keepAliveFn calls ping with the callback', () => {
|
it('keepAliveFn is set to ping function in WebSocketService', () => {
|
||||||
const cb = vi.fn();
|
expect(captured.wsOptions!.keepAliveFn).toBeDefined();
|
||||||
captured.wsOptions!.keepAliveFn(cb);
|
expect(typeof captured.wsOptions!.keepAliveFn).toBe('function');
|
||||||
expect(ping).toHaveBeenCalledWith(cb);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('onStatusChange routes to response.session.updateStatus and updates own status', () => {
|
it('onStatusChange routes to response.session.updateStatus and updates own status', () => {
|
||||||
captured.wsOptions!.onStatusChange(App.StatusEnum.CONNECTED, 'Connected');
|
captured.wsOptions!.onStatusChange(StatusEnum.CONNECTED, 'Connected');
|
||||||
expect(mockResponse.session.updateStatus).toHaveBeenCalledWith(App.StatusEnum.CONNECTED, 'Connected');
|
expect(mockResponse.session.updateStatus).toHaveBeenCalledWith(StatusEnum.CONNECTED, 'Connected');
|
||||||
expect(client.status).toBe(App.StatusEnum.CONNECTED);
|
expect(client.status).toBe(StatusEnum.CONNECTED);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('onConnectionFailed routes to response.session.connectionFailed', () => {
|
||||||
|
captured.wsOptions!.onConnectionFailed();
|
||||||
|
expect(mockResponse.session.connectionFailed).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('send closure delegates to socket.send', () => {
|
it('send closure delegates to socket.send', () => {
|
||||||
|
|
|
||||||
|
|
@ -1,52 +1,55 @@
|
||||||
import { App, Enriched } from '@app/types';
|
|
||||||
|
|
||||||
import { ProtobufService } from './services/ProtobufService';
|
|
||||||
import { WebSocketService } from './services/WebSocketService';
|
|
||||||
import { ping } from './commands/session';
|
import { ping } from './commands/session';
|
||||||
import { CLIENT_OPTIONS } from './config';
|
import { CLIENT_OPTIONS } from './config';
|
||||||
import { IWebClientResponse, IWebClientRequest } from './interfaces';
|
import type {
|
||||||
|
ConnectTarget,
|
||||||
|
IWebClientRequest,
|
||||||
|
IWebClientResponse,
|
||||||
|
} from './interfaces';
|
||||||
|
import { StatusEnum } from './interfaces';
|
||||||
|
import { ProtobufService } from './services/ProtobufService';
|
||||||
|
import { WebSocketService } from './services/WebSocketService';
|
||||||
|
|
||||||
export class WebClient {
|
export class WebClient {
|
||||||
private static _instance: WebClient | null = null;
|
private static _instance: WebClient | null = null;
|
||||||
|
|
||||||
public static get instance(): WebClient {
|
static get instance(): WebClient {
|
||||||
if (!WebClient._instance) {
|
if (!WebClient._instance) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'WebClient has not been initialized. Instantiate it via `new WebClient(response, request)` before accessing `WebClient.instance`.'
|
'WebClient has not been initialized. Instantiate it via `new WebClient()` before accessing `WebClient.instance`.'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return WebClient._instance;
|
return WebClient._instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
public socket: WebSocketService;
|
protobuf: ProtobufService;
|
||||||
public protobuf: ProtobufService;
|
socket: WebSocketService;
|
||||||
public response: IWebClientResponse;
|
status: StatusEnum;
|
||||||
public request: IWebClientRequest;
|
|
||||||
|
|
||||||
public options: Enriched.WebSocketConnectOptions | null = null;
|
constructor(
|
||||||
public status: App.StatusEnum;
|
public request: IWebClientRequest,
|
||||||
|
public response: IWebClientResponse
|
||||||
constructor(response: IWebClientResponse, request: IWebClientRequest) {
|
) {
|
||||||
if (WebClient._instance) {
|
if (WebClient._instance) {
|
||||||
throw new Error('WebClient is a singleton and has already been initialized.');
|
throw new Error('WebClient is a singleton and has already been initialized.');
|
||||||
}
|
}
|
||||||
|
|
||||||
this.response = response;
|
|
||||||
this.request = request;
|
|
||||||
|
|
||||||
this.socket = new WebSocketService({
|
this.socket = new WebSocketService({
|
||||||
keepAliveFn: (cb) => ping(cb),
|
keepAliveFn: ping,
|
||||||
response,
|
|
||||||
onStatusChange: (status, description) => {
|
onStatusChange: (status, description) => {
|
||||||
this.response.session.updateStatus(status, description);
|
this.response.session.updateStatus(status, description);
|
||||||
this.updateStatus(status);
|
this.updateStatus(status);
|
||||||
},
|
},
|
||||||
|
onConnectionFailed: () => {
|
||||||
|
this.response.session.connectionFailed();
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
this.protobuf = new ProtobufService({
|
this.protobuf = new ProtobufService(
|
||||||
|
{
|
||||||
send: (data) => this.socket.send(data),
|
send: (data) => this.socket.send(data),
|
||||||
isOpen: () => this.socket.checkReadyState(WebSocket.OPEN),
|
isOpen: () => this.socket.checkReadyState(WebSocket.OPEN),
|
||||||
});
|
}
|
||||||
|
);
|
||||||
|
|
||||||
this.socket.message$.subscribe((message: MessageEvent) => {
|
this.socket.message$.subscribe((message: MessageEvent) => {
|
||||||
this.protobuf.handleMessageEvent(message);
|
this.protobuf.handleMessageEvent(message);
|
||||||
|
|
@ -57,15 +60,14 @@ export class WebClient {
|
||||||
this.response.session.initialized();
|
this.response.session.initialized();
|
||||||
}
|
}
|
||||||
|
|
||||||
public connect(options: Enriched.WebSocketConnectOptions) {
|
public connect(target: ConnectTarget) {
|
||||||
this.response.session.connectionAttempted();
|
this.response.session.connectionAttempted();
|
||||||
this.options = options;
|
this.socket.connect(target);
|
||||||
this.socket.connect(options);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public testConnect(options: Enriched.WebSocketConnectOptions) {
|
public testConnect(target: ConnectTarget) {
|
||||||
const protocol = window.location.hostname === 'localhost' ? 'ws' : 'wss';
|
const protocol = window.location.hostname === 'localhost' ? 'ws' : 'wss';
|
||||||
const { host, port } = options;
|
const { host, port } = target;
|
||||||
const socket = new WebSocket(`${protocol}://${host}:${port}`);
|
const socket = new WebSocket(`${protocol}://${host}:${port}`);
|
||||||
socket.binaryType = 'arraybuffer';
|
socket.binaryType = 'arraybuffer';
|
||||||
|
|
||||||
|
|
@ -88,10 +90,10 @@ export class WebClient {
|
||||||
this.socket.disconnect();
|
this.socket.disconnect();
|
||||||
}
|
}
|
||||||
|
|
||||||
public updateStatus(status: App.StatusEnum) {
|
public updateStatus(status: StatusEnum) {
|
||||||
this.status = status;
|
this.status = status;
|
||||||
|
|
||||||
if (status === App.StatusEnum.DISCONNECTED) {
|
if (status === StatusEnum.DISCONNECTED) {
|
||||||
this.protobuf.resetCommands();
|
this.protobuf.resetCommands();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
185
webclient/src/websocket/__mocks__/WebClient.ts
Normal file
185
webclient/src/websocket/__mocks__/WebClient.ts
Normal file
|
|
@ -0,0 +1,185 @@
|
||||||
|
/**
|
||||||
|
* Shared WebClient mock — the single source of truth for all websocket
|
||||||
|
* layer unit tests.
|
||||||
|
*
|
||||||
|
* Vitest resolves this file whenever a spec calls `vi.mock('...WebClient')`
|
||||||
|
* without providing a factory. Each spec file gets its own module graph
|
||||||
|
* (isolate: true), so there are no factory-conflict issues.
|
||||||
|
*
|
||||||
|
* Usage in spec files:
|
||||||
|
*
|
||||||
|
* vi.mock('../../WebClient');
|
||||||
|
* import { WebClient } from '../../WebClient';
|
||||||
|
* // WebClient.instance.response.game.cardMoved ← vi.fn()
|
||||||
|
* // WebClient.instance.protobuf.sendGameCommand ← vi.fn()
|
||||||
|
*
|
||||||
|
* `useWebClientCleanup()` is NOT required — `instance` is a plain
|
||||||
|
* property, not a getter that throws.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// response.session (ISessionResponse)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const session = {
|
||||||
|
initialized: vi.fn(),
|
||||||
|
connectionAttempted: vi.fn(),
|
||||||
|
clearStore: vi.fn(),
|
||||||
|
loginSuccessful: vi.fn(),
|
||||||
|
loginFailed: vi.fn(),
|
||||||
|
connectionFailed: vi.fn(),
|
||||||
|
testConnectionSuccessful: vi.fn(),
|
||||||
|
testConnectionFailed: vi.fn(),
|
||||||
|
updateBuddyList: vi.fn(),
|
||||||
|
addToBuddyList: vi.fn(),
|
||||||
|
removeFromBuddyList: vi.fn(),
|
||||||
|
updateIgnoreList: vi.fn(),
|
||||||
|
addToIgnoreList: vi.fn(),
|
||||||
|
removeFromIgnoreList: vi.fn(),
|
||||||
|
updateInfo: vi.fn(),
|
||||||
|
updateStatus: vi.fn(),
|
||||||
|
updateUser: vi.fn(),
|
||||||
|
updateUsers: vi.fn(),
|
||||||
|
userJoined: vi.fn(),
|
||||||
|
userLeft: vi.fn(),
|
||||||
|
serverMessage: vi.fn(),
|
||||||
|
accountAwaitingActivation: vi.fn(),
|
||||||
|
accountActivationSuccess: vi.fn(),
|
||||||
|
accountActivationFailed: vi.fn(),
|
||||||
|
registrationRequiresEmail: vi.fn(),
|
||||||
|
registrationSuccess: vi.fn(),
|
||||||
|
registrationFailed: vi.fn(),
|
||||||
|
registrationEmailError: vi.fn(),
|
||||||
|
registrationPasswordError: vi.fn(),
|
||||||
|
registrationUserNameError: vi.fn(),
|
||||||
|
resetPasswordChallenge: vi.fn(),
|
||||||
|
resetPassword: vi.fn(),
|
||||||
|
resetPasswordSuccess: vi.fn(),
|
||||||
|
resetPasswordFailed: vi.fn(),
|
||||||
|
accountPasswordChange: vi.fn(),
|
||||||
|
accountEditChanged: vi.fn(),
|
||||||
|
accountImageChanged: vi.fn(),
|
||||||
|
getUserInfo: vi.fn(),
|
||||||
|
getGamesOfUser: vi.fn(),
|
||||||
|
gameJoined: vi.fn(),
|
||||||
|
notifyUser: vi.fn(),
|
||||||
|
playerPropertiesChanged: vi.fn(),
|
||||||
|
serverShutdown: vi.fn(),
|
||||||
|
userMessage: vi.fn(),
|
||||||
|
addToList: vi.fn(),
|
||||||
|
removeFromList: vi.fn(),
|
||||||
|
deleteServerDeck: vi.fn(),
|
||||||
|
updateServerDecks: vi.fn(),
|
||||||
|
uploadServerDeck: vi.fn(),
|
||||||
|
downloadServerDeck: vi.fn(),
|
||||||
|
createServerDeckDir: vi.fn(),
|
||||||
|
deleteServerDeckDir: vi.fn(),
|
||||||
|
replayList: vi.fn(),
|
||||||
|
replayAdded: vi.fn(),
|
||||||
|
replayModifyMatch: vi.fn(),
|
||||||
|
replayDeleteMatch: vi.fn(),
|
||||||
|
replayDownloaded: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// response.room (IRoomResponse)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const room = {
|
||||||
|
clearStore: vi.fn(),
|
||||||
|
joinRoom: vi.fn(),
|
||||||
|
leaveRoom: vi.fn(),
|
||||||
|
updateRooms: vi.fn(),
|
||||||
|
updateGames: vi.fn(),
|
||||||
|
addMessage: vi.fn(),
|
||||||
|
userJoined: vi.fn(),
|
||||||
|
userLeft: vi.fn(),
|
||||||
|
removeMessages: vi.fn(),
|
||||||
|
gameCreated: vi.fn(),
|
||||||
|
joinedGame: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// response.game (IGameResponse)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const game = {
|
||||||
|
clearStore: vi.fn(),
|
||||||
|
gameStateChanged: vi.fn(),
|
||||||
|
playerJoined: vi.fn(),
|
||||||
|
playerLeft: vi.fn(),
|
||||||
|
playerPropertiesChanged: vi.fn(),
|
||||||
|
gameClosed: vi.fn(),
|
||||||
|
gameHostChanged: vi.fn(),
|
||||||
|
kicked: vi.fn(),
|
||||||
|
gameSay: vi.fn(),
|
||||||
|
cardMoved: vi.fn(),
|
||||||
|
cardFlipped: vi.fn(),
|
||||||
|
cardDestroyed: vi.fn(),
|
||||||
|
cardAttached: vi.fn(),
|
||||||
|
tokenCreated: vi.fn(),
|
||||||
|
cardAttrChanged: vi.fn(),
|
||||||
|
cardCounterChanged: vi.fn(),
|
||||||
|
arrowCreated: vi.fn(),
|
||||||
|
arrowDeleted: vi.fn(),
|
||||||
|
counterCreated: vi.fn(),
|
||||||
|
counterSet: vi.fn(),
|
||||||
|
counterDeleted: vi.fn(),
|
||||||
|
cardsDrawn: vi.fn(),
|
||||||
|
cardsRevealed: vi.fn(),
|
||||||
|
zoneShuffled: vi.fn(),
|
||||||
|
dieRolled: vi.fn(),
|
||||||
|
activePlayerSet: vi.fn(),
|
||||||
|
activePhaseSet: vi.fn(),
|
||||||
|
turnReversed: vi.fn(),
|
||||||
|
zoneDumped: vi.fn(),
|
||||||
|
zonePropertiesChanged: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// response.admin (IAdminResponse)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const admin = {
|
||||||
|
adjustMod: vi.fn(),
|
||||||
|
reloadConfig: vi.fn(),
|
||||||
|
shutdownServer: vi.fn(),
|
||||||
|
updateServerMessage: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// response.moderator (IModeratorResponse)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const moderator = {
|
||||||
|
banFromServer: vi.fn(),
|
||||||
|
banHistory: vi.fn(),
|
||||||
|
viewLogs: vi.fn(),
|
||||||
|
warnHistory: vi.fn(),
|
||||||
|
warnListOptions: vi.fn(),
|
||||||
|
warnUser: vi.fn(),
|
||||||
|
grantReplayAccess: vi.fn(),
|
||||||
|
forceActivateUser: vi.fn(),
|
||||||
|
getAdminNotes: vi.fn(),
|
||||||
|
updateAdminNotes: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Exported mock — replaces the real WebClient module for all consumers.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
export const WebClient = {
|
||||||
|
_instance: null as any,
|
||||||
|
instance: {
|
||||||
|
connect: vi.fn(),
|
||||||
|
testConnect: vi.fn(),
|
||||||
|
disconnect: vi.fn(),
|
||||||
|
updateStatus: vi.fn(),
|
||||||
|
status: 0 as number,
|
||||||
|
config: {},
|
||||||
|
protobuf: {
|
||||||
|
sendSessionCommand: vi.fn(),
|
||||||
|
sendRoomCommand: vi.fn(),
|
||||||
|
sendGameCommand: vi.fn(),
|
||||||
|
sendAdminCommand: vi.fn(),
|
||||||
|
sendModeratorCommand: vi.fn(),
|
||||||
|
resetCommands: vi.fn(),
|
||||||
|
},
|
||||||
|
response: { session, room, game, admin, moderator },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
|
@ -1,8 +1,27 @@
|
||||||
/**
|
/**
|
||||||
* Shared mock factories for websocket layer unit tests.
|
* Shared mock factories for websocket layer unit tests.
|
||||||
* Import the helpers you need in each spec file via:
|
* Import the helpers you need in each spec file via:
|
||||||
* import { makeMockWebSocket } from '../__mocks__/helpers';
|
* import { makeMockWebSocket, useWebClientCleanup } from '../__mocks__/helpers';
|
||||||
*/
|
*/
|
||||||
|
import { WebClient } from '../WebClient';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resets the WebClient singleton to null. Call directly, or use
|
||||||
|
* `useWebClientCleanup()` to register automatic beforeEach/afterEach hooks.
|
||||||
|
*/
|
||||||
|
export function resetWebClientSingleton() {
|
||||||
|
(WebClient as unknown as { _instance: WebClient | null })._instance = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers beforeEach/afterEach hooks that reset the WebClient singleton.
|
||||||
|
* Call at describe-level or file-level in any spec that mocks WebClient.
|
||||||
|
* Prevents isolate:false singleton leakage between spec files.
|
||||||
|
*/
|
||||||
|
export function useWebClientCleanup() {
|
||||||
|
beforeEach(() => resetWebClientSingleton());
|
||||||
|
afterEach(() => resetWebClientSingleton());
|
||||||
|
}
|
||||||
|
|
||||||
/** Builds a mock WebSocket instance */
|
/** Builds a mock WebSocket instance */
|
||||||
export function makeMockWebSocketInstance() {
|
export function makeMockWebSocketInstance() {
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ export function makeWebClientMock() {
|
||||||
testConnect: vi.fn(),
|
testConnect: vi.fn(),
|
||||||
disconnect: vi.fn(),
|
disconnect: vi.fn(),
|
||||||
updateStatus: vi.fn(),
|
updateStatus: vi.fn(),
|
||||||
options: {},
|
config: {},
|
||||||
status: 0,
|
status: 0,
|
||||||
protobuf: {
|
protobuf: {
|
||||||
sendSessionCommand: vi.fn(),
|
sendSessionCommand: vi.fn(),
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
import { create } from '@bufbuild/protobuf';
|
import { create } from '@bufbuild/protobuf';
|
||||||
import { Data } from '@app/types';
|
import { Command_AdjustMod_ext, Command_AdjustModSchema } from '@app/generated';
|
||||||
import { WebClient } from '../../WebClient';
|
import { WebClient } from '../../WebClient';
|
||||||
export function adjustMod(userName: string, shouldBeMod?: boolean, shouldBeJudge?: boolean): void {
|
export function adjustMod(userName: string, shouldBeMod?: boolean, shouldBeJudge?: boolean): void {
|
||||||
WebClient.instance.protobuf.sendAdminCommand(
|
WebClient.instance.protobuf.sendAdminCommand(
|
||||||
Data.Command_AdjustMod_ext,
|
Command_AdjustMod_ext,
|
||||||
create(Data.Command_AdjustModSchema, { userName, shouldBeMod, shouldBeJudge }),
|
create(Command_AdjustModSchema, { userName, shouldBeMod, shouldBeJudge }),
|
||||||
{
|
{
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
WebClient.instance.response.admin.adjustMod(userName, shouldBeMod, shouldBeJudge);
|
WebClient.instance.response.admin.adjustMod(userName, shouldBeMod, shouldBeJudge);
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,4 @@
|
||||||
vi.mock('../../WebClient', () => ({
|
vi.mock('../../WebClient');
|
||||||
WebClient: {
|
|
||||||
instance: {
|
|
||||||
protobuf: { sendAdminCommand: vi.fn() },
|
|
||||||
response: {
|
|
||||||
admin: {
|
|
||||||
adjustMod: vi.fn(),
|
|
||||||
reloadConfig: vi.fn(),
|
|
||||||
shutdownServer: vi.fn(),
|
|
||||||
updateServerMessage: vi.fn(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { makeCallbackHelpers } from '../../__mocks__/callbackHelpers';
|
import { makeCallbackHelpers } from '../../__mocks__/callbackHelpers';
|
||||||
import { WebClient } from '../../WebClient';
|
import { WebClient } from '../../WebClient';
|
||||||
|
|
@ -20,6 +6,12 @@ import { adjustMod } from './adjustMod';
|
||||||
import { reloadConfig } from './reloadConfig';
|
import { reloadConfig } from './reloadConfig';
|
||||||
import { shutdownServer } from './shutdownServer';
|
import { shutdownServer } from './shutdownServer';
|
||||||
import { updateServerMessage } from './updateServerMessage';
|
import { updateServerMessage } from './updateServerMessage';
|
||||||
|
import {
|
||||||
|
Command_AdjustMod_ext,
|
||||||
|
Command_ReloadConfig_ext,
|
||||||
|
Command_ShutdownServer_ext,
|
||||||
|
Command_UpdateServerMessage_ext,
|
||||||
|
} from '@app/generated';
|
||||||
|
|
||||||
import { Mock } from 'vitest';
|
import { Mock } from 'vitest';
|
||||||
|
|
||||||
|
|
@ -33,9 +25,13 @@ const { invokeOnSuccess } = makeCallbackHelpers(
|
||||||
// ----------------------------------------------------------------
|
// ----------------------------------------------------------------
|
||||||
describe('adjustMod', () => {
|
describe('adjustMod', () => {
|
||||||
|
|
||||||
it('calls sendAdminCommand with Command_AdjustMod', () => {
|
it('calls sendAdminCommand with Command_AdjustMod extension and fields', () => {
|
||||||
adjustMod('alice', true, false);
|
adjustMod('alice', true, false);
|
||||||
expect(WebClient.instance.protobuf.sendAdminCommand).toHaveBeenCalledWith(expect.any(Object), expect.any(Object), expect.any(Object));
|
expect(WebClient.instance.protobuf.sendAdminCommand).toHaveBeenCalledWith(
|
||||||
|
Command_AdjustMod_ext,
|
||||||
|
expect.objectContaining({ userName: 'alice', shouldBeMod: true, shouldBeJudge: false }),
|
||||||
|
expect.any(Object)
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('onSuccess calls response.admin.adjustMod', () => {
|
it('onSuccess calls response.admin.adjustMod', () => {
|
||||||
|
|
@ -50,9 +46,13 @@ describe('adjustMod', () => {
|
||||||
// ----------------------------------------------------------------
|
// ----------------------------------------------------------------
|
||||||
describe('reloadConfig', () => {
|
describe('reloadConfig', () => {
|
||||||
|
|
||||||
it('calls sendAdminCommand with Command_ReloadConfig', () => {
|
it('calls sendAdminCommand with Command_ReloadConfig extension', () => {
|
||||||
reloadConfig();
|
reloadConfig();
|
||||||
expect(WebClient.instance.protobuf.sendAdminCommand).toHaveBeenCalledWith(expect.any(Object), expect.any(Object), expect.any(Object));
|
expect(WebClient.instance.protobuf.sendAdminCommand).toHaveBeenCalledWith(
|
||||||
|
Command_ReloadConfig_ext,
|
||||||
|
expect.any(Object),
|
||||||
|
expect.any(Object)
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('onSuccess calls response.admin.reloadConfig', () => {
|
it('onSuccess calls response.admin.reloadConfig', () => {
|
||||||
|
|
@ -67,9 +67,13 @@ describe('reloadConfig', () => {
|
||||||
// ----------------------------------------------------------------
|
// ----------------------------------------------------------------
|
||||||
describe('shutdownServer', () => {
|
describe('shutdownServer', () => {
|
||||||
|
|
||||||
it('calls sendAdminCommand with Command_ShutdownServer', () => {
|
it('calls sendAdminCommand with Command_ShutdownServer extension and fields', () => {
|
||||||
shutdownServer('maintenance', 10);
|
shutdownServer('maintenance', 10);
|
||||||
expect(WebClient.instance.protobuf.sendAdminCommand).toHaveBeenCalledWith(expect.any(Object), expect.any(Object), expect.any(Object));
|
expect(WebClient.instance.protobuf.sendAdminCommand).toHaveBeenCalledWith(
|
||||||
|
Command_ShutdownServer_ext,
|
||||||
|
expect.objectContaining({ reason: 'maintenance', minutes: 10 }),
|
||||||
|
expect.any(Object)
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('onSuccess calls response.admin.shutdownServer', () => {
|
it('onSuccess calls response.admin.shutdownServer', () => {
|
||||||
|
|
@ -84,9 +88,13 @@ describe('shutdownServer', () => {
|
||||||
// ----------------------------------------------------------------
|
// ----------------------------------------------------------------
|
||||||
describe('updateServerMessage', () => {
|
describe('updateServerMessage', () => {
|
||||||
|
|
||||||
it('calls sendAdminCommand with Command_UpdateServerMessage', () => {
|
it('calls sendAdminCommand with Command_UpdateServerMessage extension', () => {
|
||||||
updateServerMessage();
|
updateServerMessage();
|
||||||
expect(WebClient.instance.protobuf.sendAdminCommand).toHaveBeenCalledWith(expect.any(Object), expect.any(Object), expect.any(Object));
|
expect(WebClient.instance.protobuf.sendAdminCommand).toHaveBeenCalledWith(
|
||||||
|
Command_UpdateServerMessage_ext,
|
||||||
|
expect.any(Object),
|
||||||
|
expect.any(Object)
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('onSuccess calls response.admin.updateServerMessage', () => {
|
it('onSuccess calls response.admin.updateServerMessage', () => {
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import { create } from '@bufbuild/protobuf';
|
import { create } from '@bufbuild/protobuf';
|
||||||
import { Data } from '@app/types';
|
import { Command_ReloadConfig_ext, Command_ReloadConfigSchema } from '@app/generated';
|
||||||
import { WebClient } from '../../WebClient';
|
import { WebClient } from '../../WebClient';
|
||||||
export function reloadConfig(): void {
|
export function reloadConfig(): void {
|
||||||
WebClient.instance.protobuf.sendAdminCommand(Data.Command_ReloadConfig_ext, create(Data.Command_ReloadConfigSchema), {
|
WebClient.instance.protobuf.sendAdminCommand(Command_ReloadConfig_ext, create(Command_ReloadConfigSchema), {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
WebClient.instance.response.admin.reloadConfig();
|
WebClient.instance.response.admin.reloadConfig();
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
import { create } from '@bufbuild/protobuf';
|
import { create } from '@bufbuild/protobuf';
|
||||||
import { Data } from '@app/types';
|
import { Command_ShutdownServer_ext, Command_ShutdownServerSchema } from '@app/generated';
|
||||||
import { WebClient } from '../../WebClient';
|
import { WebClient } from '../../WebClient';
|
||||||
export function shutdownServer(reason: string, minutes: number): void {
|
export function shutdownServer(reason: string, minutes: number): void {
|
||||||
WebClient.instance.protobuf.sendAdminCommand(
|
WebClient.instance.protobuf.sendAdminCommand(
|
||||||
Data.Command_ShutdownServer_ext,
|
Command_ShutdownServer_ext,
|
||||||
create(Data.Command_ShutdownServerSchema, { reason, minutes }),
|
create(Command_ShutdownServerSchema, { reason, minutes }),
|
||||||
{
|
{
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
WebClient.instance.response.admin.shutdownServer();
|
WebClient.instance.response.admin.shutdownServer();
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import { create } from '@bufbuild/protobuf';
|
import { create } from '@bufbuild/protobuf';
|
||||||
import { Data } from '@app/types';
|
import { Command_UpdateServerMessage_ext, Command_UpdateServerMessageSchema } from '@app/generated';
|
||||||
import { WebClient } from '../../WebClient';
|
import { WebClient } from '../../WebClient';
|
||||||
export function updateServerMessage(): void {
|
export function updateServerMessage(): void {
|
||||||
WebClient.instance.protobuf.sendAdminCommand(Data.Command_UpdateServerMessage_ext, create(Data.Command_UpdateServerMessageSchema), {
|
WebClient.instance.protobuf.sendAdminCommand(Command_UpdateServerMessage_ext, create(Command_UpdateServerMessageSchema), {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
WebClient.instance.response.admin.updateServerMessage();
|
WebClient.instance.response.admin.updateServerMessage();
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
import { create } from '@bufbuild/protobuf';
|
import { create } from '@bufbuild/protobuf';
|
||||||
|
import { Command_AttachCard_ext, Command_AttachCardSchema, type AttachCardParams } from '@app/generated';
|
||||||
import { WebClient } from '../../WebClient';
|
import { WebClient } from '../../WebClient';
|
||||||
|
|
||||||
import { Data } from '@app/types';
|
export function attachCard(gameId: number, params: AttachCardParams): void {
|
||||||
|
WebClient.instance.protobuf.sendGameCommand(gameId, Command_AttachCard_ext, create(Command_AttachCardSchema, params));
|
||||||
export function attachCard(gameId: number, params: Data.AttachCardParams): void {
|
|
||||||
WebClient.instance.protobuf.sendGameCommand(gameId, Data.Command_AttachCard_ext, create(Data.Command_AttachCardSchema, params));
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,11 @@
|
||||||
import { create } from '@bufbuild/protobuf';
|
import { create } from '@bufbuild/protobuf';
|
||||||
|
import { Command_ChangeZoneProperties_ext, Command_ChangeZonePropertiesSchema, type ChangeZonePropertiesParams } from '@app/generated';
|
||||||
import { WebClient } from '../../WebClient';
|
import { WebClient } from '../../WebClient';
|
||||||
|
|
||||||
import { Data } from '@app/types';
|
export function changeZoneProperties(gameId: number, params: ChangeZonePropertiesParams): void {
|
||||||
|
|
||||||
export function changeZoneProperties(gameId: number, params: Data.ChangeZonePropertiesParams): void {
|
|
||||||
WebClient.instance.protobuf.sendGameCommand(
|
WebClient.instance.protobuf.sendGameCommand(
|
||||||
gameId,
|
gameId,
|
||||||
Data.Command_ChangeZoneProperties_ext,
|
Command_ChangeZoneProperties_ext,
|
||||||
create(Data.Command_ChangeZonePropertiesSchema, params)
|
create(Command_ChangeZonePropertiesSchema, params)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { create } from '@bufbuild/protobuf';
|
import { create } from '@bufbuild/protobuf';
|
||||||
|
import { Command_Concede_ext, Command_ConcedeSchema } from '@app/generated';
|
||||||
import { WebClient } from '../../WebClient';
|
import { WebClient } from '../../WebClient';
|
||||||
import { Data } from '@app/types';
|
|
||||||
|
|
||||||
export function concede(gameId: number): void {
|
export function concede(gameId: number): void {
|
||||||
WebClient.instance.protobuf.sendGameCommand(gameId, Data.Command_Concede_ext, create(Data.Command_ConcedeSchema));
|
WebClient.instance.protobuf.sendGameCommand(gameId, Command_Concede_ext, create(Command_ConcedeSchema));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
import { create } from '@bufbuild/protobuf';
|
import { create } from '@bufbuild/protobuf';
|
||||||
|
import { Command_CreateArrow_ext, Command_CreateArrowSchema, type CreateArrowParams } from '@app/generated';
|
||||||
import { WebClient } from '../../WebClient';
|
import { WebClient } from '../../WebClient';
|
||||||
|
|
||||||
import { Data } from '@app/types';
|
export function createArrow(gameId: number, params: CreateArrowParams): void {
|
||||||
|
WebClient.instance.protobuf.sendGameCommand(gameId, Command_CreateArrow_ext, create(Command_CreateArrowSchema, params));
|
||||||
export function createArrow(gameId: number, params: Data.CreateArrowParams): void {
|
|
||||||
WebClient.instance.protobuf.sendGameCommand(gameId, Data.Command_CreateArrow_ext, create(Data.Command_CreateArrowSchema, params));
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
import { create } from '@bufbuild/protobuf';
|
import { create } from '@bufbuild/protobuf';
|
||||||
|
import { Command_CreateCounter_ext, Command_CreateCounterSchema, type CreateCounterParams } from '@app/generated';
|
||||||
import { WebClient } from '../../WebClient';
|
import { WebClient } from '../../WebClient';
|
||||||
|
|
||||||
import { Data } from '@app/types';
|
export function createCounter(gameId: number, params: CreateCounterParams): void {
|
||||||
|
WebClient.instance.protobuf.sendGameCommand(gameId, Command_CreateCounter_ext, create(Command_CreateCounterSchema, params));
|
||||||
export function createCounter(gameId: number, params: Data.CreateCounterParams): void {
|
|
||||||
WebClient.instance.protobuf.sendGameCommand(gameId, Data.Command_CreateCounter_ext, create(Data.Command_CreateCounterSchema, params));
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
import { create } from '@bufbuild/protobuf';
|
import { create } from '@bufbuild/protobuf';
|
||||||
|
import { Command_CreateToken_ext, Command_CreateTokenSchema, type CreateTokenParams } from '@app/generated';
|
||||||
import { WebClient } from '../../WebClient';
|
import { WebClient } from '../../WebClient';
|
||||||
|
|
||||||
import { Data } from '@app/types';
|
export function createToken(gameId: number, params: CreateTokenParams): void {
|
||||||
|
WebClient.instance.protobuf.sendGameCommand(gameId, Command_CreateToken_ext, create(Command_CreateTokenSchema, params));
|
||||||
export function createToken(gameId: number, params: Data.CreateTokenParams): void {
|
|
||||||
WebClient.instance.protobuf.sendGameCommand(gameId, Data.Command_CreateToken_ext, create(Data.Command_CreateTokenSchema, params));
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
import { create } from '@bufbuild/protobuf';
|
import { create } from '@bufbuild/protobuf';
|
||||||
|
import { Command_DeckSelect_ext, Command_DeckSelectSchema, type DeckSelectParams } from '@app/generated';
|
||||||
import { WebClient } from '../../WebClient';
|
import { WebClient } from '../../WebClient';
|
||||||
|
|
||||||
import { Data } from '@app/types';
|
export function deckSelect(gameId: number, params: DeckSelectParams): void {
|
||||||
|
WebClient.instance.protobuf.sendGameCommand(gameId, Command_DeckSelect_ext, create(Command_DeckSelectSchema, params));
|
||||||
export function deckSelect(gameId: number, params: Data.DeckSelectParams): void {
|
|
||||||
WebClient.instance.protobuf.sendGameCommand(gameId, Data.Command_DeckSelect_ext, create(Data.Command_DeckSelectSchema, params));
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
import { create } from '@bufbuild/protobuf';
|
import { create } from '@bufbuild/protobuf';
|
||||||
|
import { Command_DelCounter_ext, Command_DelCounterSchema, type DelCounterParams } from '@app/generated';
|
||||||
import { WebClient } from '../../WebClient';
|
import { WebClient } from '../../WebClient';
|
||||||
|
|
||||||
import { Data } from '@app/types';
|
export function delCounter(gameId: number, params: DelCounterParams): void {
|
||||||
|
WebClient.instance.protobuf.sendGameCommand(gameId, Command_DelCounter_ext, create(Command_DelCounterSchema, params));
|
||||||
export function delCounter(gameId: number, params: Data.DelCounterParams): void {
|
|
||||||
WebClient.instance.protobuf.sendGameCommand(gameId, Data.Command_DelCounter_ext, create(Data.Command_DelCounterSchema, params));
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
import { create } from '@bufbuild/protobuf';
|
import { create } from '@bufbuild/protobuf';
|
||||||
|
import { Command_DeleteArrow_ext, Command_DeleteArrowSchema, type DeleteArrowParams } from '@app/generated';
|
||||||
import { WebClient } from '../../WebClient';
|
import { WebClient } from '../../WebClient';
|
||||||
|
|
||||||
import { Data } from '@app/types';
|
export function deleteArrow(gameId: number, params: DeleteArrowParams): void {
|
||||||
|
WebClient.instance.protobuf.sendGameCommand(gameId, Command_DeleteArrow_ext, create(Command_DeleteArrowSchema, params));
|
||||||
export function deleteArrow(gameId: number, params: Data.DeleteArrowParams): void {
|
|
||||||
WebClient.instance.protobuf.sendGameCommand(gameId, Data.Command_DeleteArrow_ext, create(Data.Command_DeleteArrowSchema, params));
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
import { create } from '@bufbuild/protobuf';
|
import { create } from '@bufbuild/protobuf';
|
||||||
|
import { Command_DrawCards_ext, Command_DrawCardsSchema, type DrawCardsParams } from '@app/generated';
|
||||||
import { WebClient } from '../../WebClient';
|
import { WebClient } from '../../WebClient';
|
||||||
|
|
||||||
import { Data } from '@app/types';
|
export function drawCards(gameId: number, params: DrawCardsParams): void {
|
||||||
|
WebClient.instance.protobuf.sendGameCommand(gameId, Command_DrawCards_ext, create(Command_DrawCardsSchema, params));
|
||||||
export function drawCards(gameId: number, params: Data.DrawCardsParams): void {
|
|
||||||
WebClient.instance.protobuf.sendGameCommand(gameId, Data.Command_DrawCards_ext, create(Data.Command_DrawCardsSchema, params));
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
import { create } from '@bufbuild/protobuf';
|
import { create } from '@bufbuild/protobuf';
|
||||||
|
import { Command_DumpZone_ext, Command_DumpZoneSchema, type DumpZoneParams } from '@app/generated';
|
||||||
import { WebClient } from '../../WebClient';
|
import { WebClient } from '../../WebClient';
|
||||||
|
|
||||||
import { Data } from '@app/types';
|
export function dumpZone(gameId: number, params: DumpZoneParams): void {
|
||||||
|
WebClient.instance.protobuf.sendGameCommand(gameId, Command_DumpZone_ext, create(Command_DumpZoneSchema, params));
|
||||||
export function dumpZone(gameId: number, params: Data.DumpZoneParams): void {
|
|
||||||
WebClient.instance.protobuf.sendGameCommand(gameId, Data.Command_DumpZone_ext, create(Data.Command_DumpZoneSchema, params));
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
import { create } from '@bufbuild/protobuf';
|
import { create } from '@bufbuild/protobuf';
|
||||||
|
import { Command_FlipCard_ext, Command_FlipCardSchema, type FlipCardParams } from '@app/generated';
|
||||||
import { WebClient } from '../../WebClient';
|
import { WebClient } from '../../WebClient';
|
||||||
|
|
||||||
import { Data } from '@app/types';
|
export function flipCard(gameId: number, params: FlipCardParams): void {
|
||||||
|
WebClient.instance.protobuf.sendGameCommand(gameId, Command_FlipCard_ext, create(Command_FlipCardSchema, params));
|
||||||
export function flipCard(gameId: number, params: Data.FlipCardParams): void {
|
|
||||||
WebClient.instance.protobuf.sendGameCommand(gameId, Data.Command_FlipCard_ext, create(Data.Command_FlipCardSchema, params));
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,45 @@
|
||||||
vi.mock('../../WebClient', () => ({
|
vi.mock('../../WebClient');
|
||||||
WebClient: {
|
|
||||||
instance: {
|
|
||||||
protobuf: { sendGameCommand: vi.fn() },
|
|
||||||
response: { game: {} },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { WebClient } from '../../WebClient';
|
import { WebClient } from '../../WebClient';
|
||||||
import { create, setExtension } from '@bufbuild/protobuf';
|
import { create, setExtension } from '@bufbuild/protobuf';
|
||||||
import { Data } from '@app/types';
|
import {
|
||||||
|
Command_AttachCard_ext,
|
||||||
|
Command_ChangeZoneProperties_ext,
|
||||||
|
Command_Concede_ext,
|
||||||
|
Command_CreateArrow_ext,
|
||||||
|
Command_CreateCounter_ext,
|
||||||
|
Command_CreateToken_ext,
|
||||||
|
Command_DeckSelect_ext,
|
||||||
|
Command_DelCounter_ext,
|
||||||
|
Command_DeleteArrow_ext,
|
||||||
|
Command_DrawCards_ext,
|
||||||
|
Command_DrawCardsSchema,
|
||||||
|
Command_DumpZone_ext,
|
||||||
|
Command_FlipCard_ext,
|
||||||
|
Command_GameSay_ext,
|
||||||
|
Command_IncCardCounter_ext,
|
||||||
|
Command_IncCounter_ext,
|
||||||
|
Command_Judge_ext,
|
||||||
|
Command_KickFromGame_ext,
|
||||||
|
Command_LeaveGame_ext,
|
||||||
|
Command_MoveCard_ext,
|
||||||
|
Command_Mulligan_ext,
|
||||||
|
Command_NextTurn_ext,
|
||||||
|
Command_ReadyStart_ext,
|
||||||
|
Command_RevealCards_ext,
|
||||||
|
Command_ReverseTurn_ext,
|
||||||
|
Command_RollDie_ext,
|
||||||
|
Command_SetActivePhase_ext,
|
||||||
|
Command_SetCardAttr_ext,
|
||||||
|
Command_SetCardCounter_ext,
|
||||||
|
Command_SetCounter_ext,
|
||||||
|
Command_SetSideboardLock_ext,
|
||||||
|
Command_SetSideboardPlan_ext,
|
||||||
|
Command_Shuffle_ext,
|
||||||
|
Command_UndoDraw_ext,
|
||||||
|
Command_Unconcede_ext,
|
||||||
|
GameCommandSchema,
|
||||||
|
} from '@app/generated';
|
||||||
|
|
||||||
import { attachCard } from './attachCard';
|
import { attachCard } from './attachCard';
|
||||||
import { changeZoneProperties } from './changeZoneProperties';
|
import { changeZoneProperties } from './changeZoneProperties';
|
||||||
|
|
@ -52,122 +82,122 @@ describe('Game commands — delegate to WebClient.instance.protobuf.sendGameComm
|
||||||
it('attachCard sends Command_AttachCard', () => {
|
it('attachCard sends Command_AttachCard', () => {
|
||||||
attachCard(gameId, { cardId: 10, startZone: 'hand' });
|
attachCard(gameId, { cardId: 10, startZone: 'hand' });
|
||||||
expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith(
|
expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith(
|
||||||
gameId, Data.Command_AttachCard_ext, expect.objectContaining({ cardId: 10, startZone: 'hand' })
|
gameId, Command_AttachCard_ext, expect.objectContaining({ cardId: 10, startZone: 'hand' })
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('changeZoneProperties sends Command_ChangeZoneProperties', () => {
|
it('changeZoneProperties sends Command_ChangeZoneProperties', () => {
|
||||||
changeZoneProperties(gameId, { zoneName: 'side' });
|
changeZoneProperties(gameId, { zoneName: 'side' });
|
||||||
expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith(
|
expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith(
|
||||||
gameId, Data.Command_ChangeZoneProperties_ext, expect.objectContaining({ zoneName: 'side' })
|
gameId, Command_ChangeZoneProperties_ext, expect.objectContaining({ zoneName: 'side' })
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('concede sends Command_Concede with empty object', () => {
|
it('concede sends Command_Concede with empty object', () => {
|
||||||
concede(gameId);
|
concede(gameId);
|
||||||
expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith(gameId, Data.Command_Concede_ext, expect.any(Object));
|
expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith(gameId, Command_Concede_ext, expect.any(Object));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('createArrow sends Command_CreateArrow', () => {
|
it('createArrow sends Command_CreateArrow', () => {
|
||||||
createArrow(gameId, { startPlayerId: 1, startZone: 'hand' });
|
createArrow(gameId, { startPlayerId: 1, startZone: 'hand' });
|
||||||
expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith(
|
expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith(
|
||||||
gameId, Data.Command_CreateArrow_ext, expect.objectContaining({ startPlayerId: 1, startZone: 'hand' })
|
gameId, Command_CreateArrow_ext, expect.objectContaining({ startPlayerId: 1, startZone: 'hand' })
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('createCounter sends Command_CreateCounter', () => {
|
it('createCounter sends Command_CreateCounter', () => {
|
||||||
createCounter(gameId, { counterName: 'life' });
|
createCounter(gameId, { counterName: 'life' });
|
||||||
expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith(
|
expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith(
|
||||||
gameId, Data.Command_CreateCounter_ext, expect.objectContaining({ counterName: 'life' })
|
gameId, Command_CreateCounter_ext, expect.objectContaining({ counterName: 'life' })
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('createToken sends Command_CreateToken', () => {
|
it('createToken sends Command_CreateToken', () => {
|
||||||
createToken(gameId, { cardName: 'Goblin', zone: 'play' });
|
createToken(gameId, { cardName: 'Goblin', zone: 'play' });
|
||||||
expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith(
|
expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith(
|
||||||
gameId, Data.Command_CreateToken_ext, expect.objectContaining({ cardName: 'Goblin', zone: 'play' })
|
gameId, Command_CreateToken_ext, expect.objectContaining({ cardName: 'Goblin', zone: 'play' })
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('deckSelect sends Command_DeckSelect', () => {
|
it('deckSelect sends Command_DeckSelect', () => {
|
||||||
deckSelect(gameId, { deckId: 5 });
|
deckSelect(gameId, { deckId: 5 });
|
||||||
expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith(
|
expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith(
|
||||||
gameId, Data.Command_DeckSelect_ext, expect.objectContaining({ deckId: 5 })
|
gameId, Command_DeckSelect_ext, expect.objectContaining({ deckId: 5 })
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('delCounter sends Command_DelCounter', () => {
|
it('delCounter sends Command_DelCounter', () => {
|
||||||
delCounter(gameId, { counterId: 3 });
|
delCounter(gameId, { counterId: 3 });
|
||||||
expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith(
|
expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith(
|
||||||
gameId, Data.Command_DelCounter_ext, expect.objectContaining({ counterId: 3 })
|
gameId, Command_DelCounter_ext, expect.objectContaining({ counterId: 3 })
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('deleteArrow sends Command_DeleteArrow', () => {
|
it('deleteArrow sends Command_DeleteArrow', () => {
|
||||||
deleteArrow(gameId, { arrowId: 2 });
|
deleteArrow(gameId, { arrowId: 2 });
|
||||||
expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith(
|
expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith(
|
||||||
gameId, Data.Command_DeleteArrow_ext, expect.objectContaining({ arrowId: 2 })
|
gameId, Command_DeleteArrow_ext, expect.objectContaining({ arrowId: 2 })
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('drawCards sends Command_DrawCards', () => {
|
it('drawCards sends Command_DrawCards', () => {
|
||||||
drawCards(gameId, { number: 3 });
|
drawCards(gameId, { number: 3 });
|
||||||
expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith(
|
expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith(
|
||||||
gameId, Data.Command_DrawCards_ext, expect.objectContaining({ number: 3 })
|
gameId, Command_DrawCards_ext, expect.objectContaining({ number: 3 })
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('dumpZone sends Command_DumpZone', () => {
|
it('dumpZone sends Command_DumpZone', () => {
|
||||||
dumpZone(gameId, { playerId: 2, zoneName: 'library' });
|
dumpZone(gameId, { playerId: 2, zoneName: 'library' });
|
||||||
expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith(
|
expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith(
|
||||||
gameId, Data.Command_DumpZone_ext, expect.objectContaining({ playerId: 2, zoneName: 'library' })
|
gameId, Command_DumpZone_ext, expect.objectContaining({ playerId: 2, zoneName: 'library' })
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('flipCard sends Command_FlipCard', () => {
|
it('flipCard sends Command_FlipCard', () => {
|
||||||
flipCard(gameId, { cardId: 7, faceDown: false });
|
flipCard(gameId, { cardId: 7, faceDown: false });
|
||||||
expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith(
|
expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith(
|
||||||
gameId, Data.Command_FlipCard_ext, expect.objectContaining({ cardId: 7, faceDown: false })
|
gameId, Command_FlipCard_ext, expect.objectContaining({ cardId: 7, faceDown: false })
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('gameSay sends Command_GameSay', () => {
|
it('gameSay sends Command_GameSay', () => {
|
||||||
gameSay(gameId, { message: 'hello' });
|
gameSay(gameId, { message: 'hello' });
|
||||||
expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith(
|
expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith(
|
||||||
gameId, Data.Command_GameSay_ext, expect.objectContaining({ message: 'hello' })
|
gameId, Command_GameSay_ext, expect.objectContaining({ message: 'hello' })
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('incCardCounter sends Command_IncCardCounter', () => {
|
it('incCardCounter sends Command_IncCardCounter', () => {
|
||||||
incCardCounter(gameId, { cardId: 5, counterId: 1 });
|
incCardCounter(gameId, { cardId: 5, counterId: 1 });
|
||||||
expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith(
|
expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith(
|
||||||
gameId, Data.Command_IncCardCounter_ext, expect.objectContaining({ cardId: 5, counterId: 1 })
|
gameId, Command_IncCardCounter_ext, expect.objectContaining({ cardId: 5, counterId: 1 })
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('incCounter sends Command_IncCounter', () => {
|
it('incCounter sends Command_IncCounter', () => {
|
||||||
incCounter(gameId, { counterId: 1, delta: 5 });
|
incCounter(gameId, { counterId: 1, delta: 5 });
|
||||||
expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith(
|
expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith(
|
||||||
gameId, Data.Command_IncCounter_ext, expect.objectContaining({ counterId: 1, delta: 5 })
|
gameId, Command_IncCounter_ext, expect.objectContaining({ counterId: 1, delta: 5 })
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('kickFromGame sends Command_KickFromGame', () => {
|
it('kickFromGame sends Command_KickFromGame', () => {
|
||||||
kickFromGame(gameId, { playerId: 2 });
|
kickFromGame(gameId, { playerId: 2 });
|
||||||
expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith(
|
expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith(
|
||||||
gameId, Data.Command_KickFromGame_ext, expect.objectContaining({ playerId: 2 })
|
gameId, Command_KickFromGame_ext, expect.objectContaining({ playerId: 2 })
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('leaveGame sends Command_LeaveGame with empty object', () => {
|
it('leaveGame sends Command_LeaveGame with empty object', () => {
|
||||||
leaveGame(gameId);
|
leaveGame(gameId);
|
||||||
expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith(gameId, Data.Command_LeaveGame_ext, expect.any(Object));
|
expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith(gameId, Command_LeaveGame_ext, expect.any(Object));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('moveCard sends Command_MoveCard', () => {
|
it('moveCard sends Command_MoveCard', () => {
|
||||||
moveCard(gameId, { startZone: 'hand', targetZone: 'graveyard' });
|
moveCard(gameId, { startZone: 'hand', targetZone: 'graveyard' });
|
||||||
expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith(
|
expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith(
|
||||||
gameId, Data.Command_MoveCard_ext,
|
gameId, Command_MoveCard_ext,
|
||||||
expect.objectContaining({ startZone: 'hand', targetZone: 'graveyard' })
|
expect.objectContaining({ startZone: 'hand', targetZone: 'graveyard' })
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
@ -175,45 +205,45 @@ describe('Game commands — delegate to WebClient.instance.protobuf.sendGameComm
|
||||||
it('mulligan sends Command_Mulligan', () => {
|
it('mulligan sends Command_Mulligan', () => {
|
||||||
mulligan(gameId, { number: 7 });
|
mulligan(gameId, { number: 7 });
|
||||||
expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith(
|
expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith(
|
||||||
gameId, Data.Command_Mulligan_ext, expect.objectContaining({ number: 7 })
|
gameId, Command_Mulligan_ext, expect.objectContaining({ number: 7 })
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('nextTurn sends Command_NextTurn with empty object', () => {
|
it('nextTurn sends Command_NextTurn with empty object', () => {
|
||||||
nextTurn(gameId);
|
nextTurn(gameId);
|
||||||
expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith(gameId, Data.Command_NextTurn_ext, expect.any(Object));
|
expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith(gameId, Command_NextTurn_ext, expect.any(Object));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('readyStart sends Command_ReadyStart', () => {
|
it('readyStart sends Command_ReadyStart', () => {
|
||||||
readyStart(gameId, { ready: true });
|
readyStart(gameId, { ready: true });
|
||||||
expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith(
|
expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith(
|
||||||
gameId, Data.Command_ReadyStart_ext, expect.objectContaining({ ready: true })
|
gameId, Command_ReadyStart_ext, expect.objectContaining({ ready: true })
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('revealCards sends Command_RevealCards', () => {
|
it('revealCards sends Command_RevealCards', () => {
|
||||||
revealCards(gameId, { zoneName: 'hand', cardId: [1, 2] });
|
revealCards(gameId, { zoneName: 'hand', cardId: [1, 2] });
|
||||||
expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith(
|
expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith(
|
||||||
gameId, Data.Command_RevealCards_ext, expect.objectContaining({ zoneName: 'hand', cardId: [1, 2] })
|
gameId, Command_RevealCards_ext, expect.objectContaining({ zoneName: 'hand', cardId: [1, 2] })
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('reverseTurn sends Command_ReverseTurn with empty object', () => {
|
it('reverseTurn sends Command_ReverseTurn with empty object', () => {
|
||||||
reverseTurn(gameId);
|
reverseTurn(gameId);
|
||||||
expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith(gameId, Data.Command_ReverseTurn_ext, expect.any(Object));
|
expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith(gameId, Command_ReverseTurn_ext, expect.any(Object));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('setActivePhase sends Command_SetActivePhase', () => {
|
it('setActivePhase sends Command_SetActivePhase', () => {
|
||||||
setActivePhase(gameId, { phase: 2 });
|
setActivePhase(gameId, { phase: 2 });
|
||||||
expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith(
|
expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith(
|
||||||
gameId, Data.Command_SetActivePhase_ext, expect.objectContaining({ phase: 2 })
|
gameId, Command_SetActivePhase_ext, expect.objectContaining({ phase: 2 })
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('setCardAttr sends Command_SetCardAttr', () => {
|
it('setCardAttr sends Command_SetCardAttr', () => {
|
||||||
setCardAttr(gameId, { zone: 'play', cardId: 5, attrValue: '2' });
|
setCardAttr(gameId, { zone: 'play', cardId: 5, attrValue: '2' });
|
||||||
expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith(
|
expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith(
|
||||||
gameId, Data.Command_SetCardAttr_ext,
|
gameId, Command_SetCardAttr_ext,
|
||||||
expect.objectContaining({ zone: 'play', cardId: 5, attrValue: '2' })
|
expect.objectContaining({ zone: 'play', cardId: 5, attrValue: '2' })
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
@ -221,63 +251,63 @@ describe('Game commands — delegate to WebClient.instance.protobuf.sendGameComm
|
||||||
it('setCardCounter sends Command_SetCardCounter', () => {
|
it('setCardCounter sends Command_SetCardCounter', () => {
|
||||||
setCardCounter(gameId, { cardId: 5, counterId: 1 });
|
setCardCounter(gameId, { cardId: 5, counterId: 1 });
|
||||||
expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith(
|
expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith(
|
||||||
gameId, Data.Command_SetCardCounter_ext, expect.objectContaining({ cardId: 5, counterId: 1 })
|
gameId, Command_SetCardCounter_ext, expect.objectContaining({ cardId: 5, counterId: 1 })
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('setCounter sends Command_SetCounter', () => {
|
it('setCounter sends Command_SetCounter', () => {
|
||||||
setCounter(gameId, { counterId: 1, value: 10 });
|
setCounter(gameId, { counterId: 1, value: 10 });
|
||||||
expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith(
|
expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith(
|
||||||
gameId, Data.Command_SetCounter_ext, expect.objectContaining({ counterId: 1, value: 10 })
|
gameId, Command_SetCounter_ext, expect.objectContaining({ counterId: 1, value: 10 })
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('setSideboardLock sends Command_SetSideboardLock', () => {
|
it('setSideboardLock sends Command_SetSideboardLock', () => {
|
||||||
setSideboardLock(gameId, { locked: true });
|
setSideboardLock(gameId, { locked: true });
|
||||||
expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith(
|
expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith(
|
||||||
gameId, Data.Command_SetSideboardLock_ext, expect.objectContaining({ locked: true })
|
gameId, Command_SetSideboardLock_ext, expect.objectContaining({ locked: true })
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('setSideboardPlan sends Command_SetSideboardPlan', () => {
|
it('setSideboardPlan sends Command_SetSideboardPlan', () => {
|
||||||
setSideboardPlan(gameId, { moveList: [] });
|
setSideboardPlan(gameId, { moveList: [] });
|
||||||
expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith(
|
expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith(
|
||||||
gameId, Data.Command_SetSideboardPlan_ext, expect.objectContaining({ moveList: expect.any(Array) })
|
gameId, Command_SetSideboardPlan_ext, expect.objectContaining({ moveList: expect.any(Array) })
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shuffle sends Command_Shuffle', () => {
|
it('shuffle sends Command_Shuffle', () => {
|
||||||
shuffle(gameId, { zoneName: 'hand' });
|
shuffle(gameId, { zoneName: 'hand' });
|
||||||
expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith(
|
expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith(
|
||||||
gameId, Data.Command_Shuffle_ext, expect.objectContaining({ zoneName: 'hand' })
|
gameId, Command_Shuffle_ext, expect.objectContaining({ zoneName: 'hand' })
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('undoDraw sends Command_UndoDraw with empty object', () => {
|
it('undoDraw sends Command_UndoDraw with empty object', () => {
|
||||||
undoDraw(gameId);
|
undoDraw(gameId);
|
||||||
expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith(gameId, Data.Command_UndoDraw_ext, expect.any(Object));
|
expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith(gameId, Command_UndoDraw_ext, expect.any(Object));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('unconcede sends Command_Unconcede with empty object', () => {
|
it('unconcede sends Command_Unconcede with empty object', () => {
|
||||||
unconcede(gameId);
|
unconcede(gameId);
|
||||||
expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith(gameId, Data.Command_Unconcede_ext, expect.any(Object));
|
expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith(gameId, Command_Unconcede_ext, expect.any(Object));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rollDie sends Command_RollDie', () => {
|
it('rollDie sends Command_RollDie', () => {
|
||||||
rollDie(gameId, { sides: 6, count: 2 });
|
rollDie(gameId, { sides: 6, count: 2 });
|
||||||
expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith(
|
expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith(
|
||||||
gameId, Data.Command_RollDie_ext, expect.objectContaining({ sides: 6, count: 2 })
|
gameId, Command_RollDie_ext, expect.objectContaining({ sides: 6, count: 2 })
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('judge sends Command_Judge with targetId and wrapped gameCommand array', () => {
|
it('judge sends Command_Judge with targetId and wrapped gameCommand array', () => {
|
||||||
const targetId = 3;
|
const targetId = 3;
|
||||||
const innerCmd = create(Data.GameCommandSchema);
|
const innerCmd = create(GameCommandSchema);
|
||||||
setExtension(innerCmd, Data.Command_DrawCards_ext, create(Data.Command_DrawCardsSchema, { number: 2 }));
|
setExtension(innerCmd, Command_DrawCards_ext, create(Command_DrawCardsSchema, { number: 2 }));
|
||||||
judge(gameId, targetId, innerCmd);
|
judge(gameId, targetId, innerCmd);
|
||||||
expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith(
|
expect(WebClient.instance.protobuf.sendGameCommand).toHaveBeenCalledWith(
|
||||||
gameId,
|
gameId,
|
||||||
Data.Command_Judge_ext,
|
Command_Judge_ext,
|
||||||
expect.objectContaining({ targetId: 3, gameCommand: expect.any(Array) })
|
expect.objectContaining({ targetId: 3, gameCommand: expect.any(Array) })
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
import { create } from '@bufbuild/protobuf';
|
import { create } from '@bufbuild/protobuf';
|
||||||
|
import { Command_GameSay_ext, Command_GameSaySchema, type GameSayParams } from '@app/generated';
|
||||||
import { WebClient } from '../../WebClient';
|
import { WebClient } from '../../WebClient';
|
||||||
|
|
||||||
import { Data } from '@app/types';
|
export function gameSay(gameId: number, params: GameSayParams): void {
|
||||||
|
WebClient.instance.protobuf.sendGameCommand(gameId, Command_GameSay_ext, create(Command_GameSaySchema, params));
|
||||||
export function gameSay(gameId: number, params: Data.GameSayParams): void {
|
|
||||||
WebClient.instance.protobuf.sendGameCommand(gameId, Data.Command_GameSay_ext, create(Data.Command_GameSaySchema, params));
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
import { create } from '@bufbuild/protobuf';
|
import { create } from '@bufbuild/protobuf';
|
||||||
|
import { Command_IncCardCounter_ext, Command_IncCardCounterSchema, type IncCardCounterParams } from '@app/generated';
|
||||||
import { WebClient } from '../../WebClient';
|
import { WebClient } from '../../WebClient';
|
||||||
|
|
||||||
import { Data } from '@app/types';
|
export function incCardCounter(gameId: number, params: IncCardCounterParams): void {
|
||||||
|
WebClient.instance.protobuf.sendGameCommand(gameId, Command_IncCardCounter_ext, create(Command_IncCardCounterSchema, params));
|
||||||
export function incCardCounter(gameId: number, params: Data.IncCardCounterParams): void {
|
|
||||||
WebClient.instance.protobuf.sendGameCommand(gameId, Data.Command_IncCardCounter_ext, create(Data.Command_IncCardCounterSchema, params));
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
import { create } from '@bufbuild/protobuf';
|
import { create } from '@bufbuild/protobuf';
|
||||||
|
import { Command_IncCounter_ext, Command_IncCounterSchema, type IncCounterParams } from '@app/generated';
|
||||||
import { WebClient } from '../../WebClient';
|
import { WebClient } from '../../WebClient';
|
||||||
|
|
||||||
import { Data } from '@app/types';
|
export function incCounter(gameId: number, params: IncCounterParams): void {
|
||||||
|
WebClient.instance.protobuf.sendGameCommand(gameId, Command_IncCounter_ext, create(Command_IncCounterSchema, params));
|
||||||
export function incCounter(gameId: number, params: Data.IncCounterParams): void {
|
|
||||||
WebClient.instance.protobuf.sendGameCommand(gameId, Data.Command_IncCounter_ext, create(Data.Command_IncCounterSchema, params));
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,10 @@
|
||||||
import { create } from '@bufbuild/protobuf';
|
import { create } from '@bufbuild/protobuf';
|
||||||
|
import { Command_Judge_ext, Command_JudgeSchema, type GameCommand } from '@app/generated';
|
||||||
import { WebClient } from '../../WebClient';
|
import { WebClient } from '../../WebClient';
|
||||||
import { Data } from '@app/types';
|
|
||||||
|
|
||||||
export function judge(gameId: number, targetId: number, innerGameCommand: Data.GameCommand): void {
|
export function judge(gameId: number, targetId: number, innerGameCommand: GameCommand): void {
|
||||||
WebClient.instance.protobuf.sendGameCommand(gameId, Data.Command_Judge_ext, create(Data.Command_JudgeSchema, {
|
WebClient.instance.protobuf.sendGameCommand(gameId, Command_Judge_ext, create(Command_JudgeSchema, {
|
||||||
targetId,
|
targetId,
|
||||||
gameCommand: [innerGameCommand],
|
gameCommand: [innerGameCommand],
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
import { create } from '@bufbuild/protobuf';
|
import { create } from '@bufbuild/protobuf';
|
||||||
|
import { Command_KickFromGame_ext, Command_KickFromGameSchema, type KickFromGameParams } from '@app/generated';
|
||||||
import { WebClient } from '../../WebClient';
|
import { WebClient } from '../../WebClient';
|
||||||
|
|
||||||
import { Data } from '@app/types';
|
export function kickFromGame(gameId: number, params: KickFromGameParams): void {
|
||||||
|
WebClient.instance.protobuf.sendGameCommand(gameId, Command_KickFromGame_ext, create(Command_KickFromGameSchema, params));
|
||||||
export function kickFromGame(gameId: number, params: Data.KickFromGameParams): void {
|
|
||||||
WebClient.instance.protobuf.sendGameCommand(gameId, Data.Command_KickFromGame_ext, create(Data.Command_KickFromGameSchema, params));
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { create } from '@bufbuild/protobuf';
|
import { create } from '@bufbuild/protobuf';
|
||||||
|
import { Command_LeaveGame_ext, Command_LeaveGameSchema } from '@app/generated';
|
||||||
import { WebClient } from '../../WebClient';
|
import { WebClient } from '../../WebClient';
|
||||||
import { Data } from '@app/types';
|
|
||||||
|
|
||||||
export function leaveGame(gameId: number): void {
|
export function leaveGame(gameId: number): void {
|
||||||
WebClient.instance.protobuf.sendGameCommand(gameId, Data.Command_LeaveGame_ext, create(Data.Command_LeaveGameSchema));
|
WebClient.instance.protobuf.sendGameCommand(gameId, Command_LeaveGame_ext, create(Command_LeaveGameSchema));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
import { create } from '@bufbuild/protobuf';
|
import { create } from '@bufbuild/protobuf';
|
||||||
|
import { Command_MoveCard_ext, Command_MoveCardSchema, type MoveCardParams } from '@app/generated';
|
||||||
import { WebClient } from '../../WebClient';
|
import { WebClient } from '../../WebClient';
|
||||||
|
|
||||||
import { Data } from '@app/types';
|
export function moveCard(gameId: number, params: MoveCardParams): void {
|
||||||
|
WebClient.instance.protobuf.sendGameCommand(gameId, Command_MoveCard_ext, create(Command_MoveCardSchema, params));
|
||||||
export function moveCard(gameId: number, params: Data.MoveCardParams): void {
|
|
||||||
WebClient.instance.protobuf.sendGameCommand(gameId, Data.Command_MoveCard_ext, create(Data.Command_MoveCardSchema, params));
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
import { create } from '@bufbuild/protobuf';
|
import { create } from '@bufbuild/protobuf';
|
||||||
|
import { Command_Mulligan_ext, Command_MulliganSchema, type MulliganParams } from '@app/generated';
|
||||||
import { WebClient } from '../../WebClient';
|
import { WebClient } from '../../WebClient';
|
||||||
|
|
||||||
import { Data } from '@app/types';
|
export function mulligan(gameId: number, params: MulliganParams): void {
|
||||||
|
WebClient.instance.protobuf.sendGameCommand(gameId, Command_Mulligan_ext, create(Command_MulliganSchema, params));
|
||||||
export function mulligan(gameId: number, params: Data.MulliganParams): void {
|
|
||||||
WebClient.instance.protobuf.sendGameCommand(gameId, Data.Command_Mulligan_ext, create(Data.Command_MulliganSchema, params));
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { create } from '@bufbuild/protobuf';
|
import { create } from '@bufbuild/protobuf';
|
||||||
|
import { Command_NextTurn_ext, Command_NextTurnSchema } from '@app/generated';
|
||||||
import { WebClient } from '../../WebClient';
|
import { WebClient } from '../../WebClient';
|
||||||
import { Data } from '@app/types';
|
|
||||||
|
|
||||||
export function nextTurn(gameId: number): void {
|
export function nextTurn(gameId: number): void {
|
||||||
WebClient.instance.protobuf.sendGameCommand(gameId, Data.Command_NextTurn_ext, create(Data.Command_NextTurnSchema));
|
WebClient.instance.protobuf.sendGameCommand(gameId, Command_NextTurn_ext, create(Command_NextTurnSchema));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
import { create } from '@bufbuild/protobuf';
|
import { create } from '@bufbuild/protobuf';
|
||||||
|
import { Command_ReadyStart_ext, Command_ReadyStartSchema, type ReadyStartParams } from '@app/generated';
|
||||||
import { WebClient } from '../../WebClient';
|
import { WebClient } from '../../WebClient';
|
||||||
|
|
||||||
import { Data } from '@app/types';
|
export function readyStart(gameId: number, params: ReadyStartParams): void {
|
||||||
|
WebClient.instance.protobuf.sendGameCommand(gameId, Command_ReadyStart_ext, create(Command_ReadyStartSchema, params));
|
||||||
export function readyStart(gameId: number, params: Data.ReadyStartParams): void {
|
|
||||||
WebClient.instance.protobuf.sendGameCommand(gameId, Data.Command_ReadyStart_ext, create(Data.Command_ReadyStartSchema, params));
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
import { create } from '@bufbuild/protobuf';
|
import { create } from '@bufbuild/protobuf';
|
||||||
|
import { Command_RevealCards_ext, Command_RevealCardsSchema, type RevealCardsParams } from '@app/generated';
|
||||||
import { WebClient } from '../../WebClient';
|
import { WebClient } from '../../WebClient';
|
||||||
|
|
||||||
import { Data } from '@app/types';
|
export function revealCards(gameId: number, params: RevealCardsParams): void {
|
||||||
|
WebClient.instance.protobuf.sendGameCommand(gameId, Command_RevealCards_ext, create(Command_RevealCardsSchema, params));
|
||||||
export function revealCards(gameId: number, params: Data.RevealCardsParams): void {
|
|
||||||
WebClient.instance.protobuf.sendGameCommand(gameId, Data.Command_RevealCards_ext, create(Data.Command_RevealCardsSchema, params));
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { create } from '@bufbuild/protobuf';
|
import { create } from '@bufbuild/protobuf';
|
||||||
|
import { Command_ReverseTurn_ext, Command_ReverseTurnSchema } from '@app/generated';
|
||||||
import { WebClient } from '../../WebClient';
|
import { WebClient } from '../../WebClient';
|
||||||
import { Data } from '@app/types';
|
|
||||||
|
|
||||||
export function reverseTurn(gameId: number): void {
|
export function reverseTurn(gameId: number): void {
|
||||||
WebClient.instance.protobuf.sendGameCommand(gameId, Data.Command_ReverseTurn_ext, create(Data.Command_ReverseTurnSchema));
|
WebClient.instance.protobuf.sendGameCommand(gameId, Command_ReverseTurn_ext, create(Command_ReverseTurnSchema));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
import { create } from '@bufbuild/protobuf';
|
import { create } from '@bufbuild/protobuf';
|
||||||
|
import { Command_RollDie_ext, Command_RollDieSchema, type RollDieParams } from '@app/generated';
|
||||||
import { WebClient } from '../../WebClient';
|
import { WebClient } from '../../WebClient';
|
||||||
|
|
||||||
import { Data } from '@app/types';
|
export function rollDie(gameId: number, params: RollDieParams): void {
|
||||||
|
WebClient.instance.protobuf.sendGameCommand(gameId, Command_RollDie_ext, create(Command_RollDieSchema, params));
|
||||||
export function rollDie(gameId: number, params: Data.RollDieParams): void {
|
|
||||||
WebClient.instance.protobuf.sendGameCommand(gameId, Data.Command_RollDie_ext, create(Data.Command_RollDieSchema, params));
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
import { create } from '@bufbuild/protobuf';
|
import { create } from '@bufbuild/protobuf';
|
||||||
|
import { Command_SetActivePhase_ext, Command_SetActivePhaseSchema, type SetActivePhaseParams } from '@app/generated';
|
||||||
import { WebClient } from '../../WebClient';
|
import { WebClient } from '../../WebClient';
|
||||||
|
|
||||||
import { Data } from '@app/types';
|
export function setActivePhase(gameId: number, params: SetActivePhaseParams): void {
|
||||||
|
WebClient.instance.protobuf.sendGameCommand(gameId, Command_SetActivePhase_ext, create(Command_SetActivePhaseSchema, params));
|
||||||
export function setActivePhase(gameId: number, params: Data.SetActivePhaseParams): void {
|
|
||||||
WebClient.instance.protobuf.sendGameCommand(gameId, Data.Command_SetActivePhase_ext, create(Data.Command_SetActivePhaseSchema, params));
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
import { create } from '@bufbuild/protobuf';
|
import { create } from '@bufbuild/protobuf';
|
||||||
|
import { Command_SetCardAttr_ext, Command_SetCardAttrSchema, type SetCardAttrParams } from '@app/generated';
|
||||||
import { WebClient } from '../../WebClient';
|
import { WebClient } from '../../WebClient';
|
||||||
|
|
||||||
import { Data } from '@app/types';
|
export function setCardAttr(gameId: number, params: SetCardAttrParams): void {
|
||||||
|
WebClient.instance.protobuf.sendGameCommand(gameId, Command_SetCardAttr_ext, create(Command_SetCardAttrSchema, params));
|
||||||
export function setCardAttr(gameId: number, params: Data.SetCardAttrParams): void {
|
|
||||||
WebClient.instance.protobuf.sendGameCommand(gameId, Data.Command_SetCardAttr_ext, create(Data.Command_SetCardAttrSchema, params));
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
import { create } from '@bufbuild/protobuf';
|
import { create } from '@bufbuild/protobuf';
|
||||||
|
import { Command_SetCardCounter_ext, Command_SetCardCounterSchema, type SetCardCounterParams } from '@app/generated';
|
||||||
import { WebClient } from '../../WebClient';
|
import { WebClient } from '../../WebClient';
|
||||||
|
|
||||||
import { Data } from '@app/types';
|
export function setCardCounter(gameId: number, params: SetCardCounterParams): void {
|
||||||
|
WebClient.instance.protobuf.sendGameCommand(gameId, Command_SetCardCounter_ext, create(Command_SetCardCounterSchema, params));
|
||||||
export function setCardCounter(gameId: number, params: Data.SetCardCounterParams): void {
|
|
||||||
WebClient.instance.protobuf.sendGameCommand(gameId, Data.Command_SetCardCounter_ext, create(Data.Command_SetCardCounterSchema, params));
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
import { create } from '@bufbuild/protobuf';
|
import { create } from '@bufbuild/protobuf';
|
||||||
|
import { Command_SetCounter_ext, Command_SetCounterSchema, type SetCounterParams } from '@app/generated';
|
||||||
import { WebClient } from '../../WebClient';
|
import { WebClient } from '../../WebClient';
|
||||||
|
|
||||||
import { Data } from '@app/types';
|
export function setCounter(gameId: number, params: SetCounterParams): void {
|
||||||
|
WebClient.instance.protobuf.sendGameCommand(gameId, Command_SetCounter_ext, create(Command_SetCounterSchema, params));
|
||||||
export function setCounter(gameId: number, params: Data.SetCounterParams): void {
|
|
||||||
WebClient.instance.protobuf.sendGameCommand(gameId, Data.Command_SetCounter_ext, create(Data.Command_SetCounterSchema, params));
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,11 @@
|
||||||
import { create } from '@bufbuild/protobuf';
|
import { create } from '@bufbuild/protobuf';
|
||||||
|
import { Command_SetSideboardLock_ext, Command_SetSideboardLockSchema, type SetSideboardLockParams } from '@app/generated';
|
||||||
import { WebClient } from '../../WebClient';
|
import { WebClient } from '../../WebClient';
|
||||||
|
|
||||||
import { Data } from '@app/types';
|
export function setSideboardLock(gameId: number, params: SetSideboardLockParams): void {
|
||||||
|
|
||||||
export function setSideboardLock(gameId: number, params: Data.SetSideboardLockParams): void {
|
|
||||||
WebClient.instance.protobuf.sendGameCommand(
|
WebClient.instance.protobuf.sendGameCommand(
|
||||||
gameId,
|
gameId,
|
||||||
Data.Command_SetSideboardLock_ext,
|
Command_SetSideboardLock_ext,
|
||||||
create(Data.Command_SetSideboardLockSchema, params)
|
create(Command_SetSideboardLockSchema, params)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,11 @@
|
||||||
import { create } from '@bufbuild/protobuf';
|
import { create } from '@bufbuild/protobuf';
|
||||||
|
import { Command_SetSideboardPlan_ext, Command_SetSideboardPlanSchema, type SetSideboardPlanParams } from '@app/generated';
|
||||||
import { WebClient } from '../../WebClient';
|
import { WebClient } from '../../WebClient';
|
||||||
|
|
||||||
import { Data } from '@app/types';
|
export function setSideboardPlan(gameId: number, params: SetSideboardPlanParams): void {
|
||||||
|
|
||||||
export function setSideboardPlan(gameId: number, params: Data.SetSideboardPlanParams): void {
|
|
||||||
WebClient.instance.protobuf.sendGameCommand(
|
WebClient.instance.protobuf.sendGameCommand(
|
||||||
gameId,
|
gameId,
|
||||||
Data.Command_SetSideboardPlan_ext,
|
Command_SetSideboardPlan_ext,
|
||||||
create(Data.Command_SetSideboardPlanSchema, params)
|
create(Command_SetSideboardPlanSchema, params)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
import { create } from '@bufbuild/protobuf';
|
import { create } from '@bufbuild/protobuf';
|
||||||
|
import { Command_Shuffle_ext, Command_ShuffleSchema, type ShuffleParams } from '@app/generated';
|
||||||
import { WebClient } from '../../WebClient';
|
import { WebClient } from '../../WebClient';
|
||||||
|
|
||||||
import { Data } from '@app/types';
|
export function shuffle(gameId: number, params: ShuffleParams): void {
|
||||||
|
WebClient.instance.protobuf.sendGameCommand(gameId, Command_Shuffle_ext, create(Command_ShuffleSchema, params));
|
||||||
export function shuffle(gameId: number, params: Data.ShuffleParams): void {
|
|
||||||
WebClient.instance.protobuf.sendGameCommand(gameId, Data.Command_Shuffle_ext, create(Data.Command_ShuffleSchema, params));
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { create } from '@bufbuild/protobuf';
|
import { create } from '@bufbuild/protobuf';
|
||||||
|
import { Command_Unconcede_ext, Command_UnconcedeSchema } from '@app/generated';
|
||||||
import { WebClient } from '../../WebClient';
|
import { WebClient } from '../../WebClient';
|
||||||
import { Data } from '@app/types';
|
|
||||||
|
|
||||||
export function unconcede(gameId: number): void {
|
export function unconcede(gameId: number): void {
|
||||||
WebClient.instance.protobuf.sendGameCommand(gameId, Data.Command_Unconcede_ext, create(Data.Command_UnconcedeSchema));
|
WebClient.instance.protobuf.sendGameCommand(gameId, Command_Unconcede_ext, create(Command_UnconcedeSchema));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { create } from '@bufbuild/protobuf';
|
import { create } from '@bufbuild/protobuf';
|
||||||
|
import { Command_UndoDraw_ext, Command_UndoDrawSchema } from '@app/generated';
|
||||||
import { WebClient } from '../../WebClient';
|
import { WebClient } from '../../WebClient';
|
||||||
import { Data } from '@app/types';
|
|
||||||
|
|
||||||
export function undoDraw(gameId: number): void {
|
export function undoDraw(gameId: number): void {
|
||||||
WebClient.instance.protobuf.sendGameCommand(gameId, Data.Command_UndoDraw_ext, create(Data.Command_UndoDrawSchema));
|
WebClient.instance.protobuf.sendGameCommand(gameId, Command_UndoDraw_ext, create(Command_UndoDrawSchema));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
import { create } from '@bufbuild/protobuf';
|
import { create } from '@bufbuild/protobuf';
|
||||||
import { WebClient } from '../../WebClient';
|
import { WebClient } from '../../WebClient';
|
||||||
|
|
||||||
import { Data } from '@app/types';
|
import { Command_BanFromServer_ext, Command_BanFromServerSchema } from '@app/generated';
|
||||||
|
|
||||||
export function banFromServer(minutes: number, userName?: string, address?: string, reason?: string,
|
export function banFromServer(minutes: number, userName?: string, address?: string, reason?: string,
|
||||||
visibleReason?: string, clientid?: string, removeMessages?: number): void {
|
visibleReason?: string, clientid?: string, removeMessages?: number): void {
|
||||||
WebClient.instance.protobuf.sendModeratorCommand(Data.Command_BanFromServer_ext, create(Data.Command_BanFromServerSchema, {
|
WebClient.instance.protobuf.sendModeratorCommand(Command_BanFromServer_ext, create(Command_BanFromServerSchema, {
|
||||||
minutes, userName, address, reason, visibleReason, clientid, removeMessages
|
minutes, userName, address, reason, visibleReason, clientid, removeMessages
|
||||||
}), {
|
}), {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
import { create } from '@bufbuild/protobuf';
|
import { create } from '@bufbuild/protobuf';
|
||||||
import { WebClient } from '../../WebClient';
|
import { WebClient } from '../../WebClient';
|
||||||
|
|
||||||
import { Data } from '@app/types';
|
import { Command_ForceActivateUser_ext, Command_ForceActivateUserSchema } from '@app/generated';
|
||||||
|
|
||||||
export function forceActivateUser(usernameToActivate: string, moderatorName: string): void {
|
export function forceActivateUser(usernameToActivate: string, moderatorName: string): void {
|
||||||
const cmd = create(Data.Command_ForceActivateUserSchema, { usernameToActivate, moderatorName });
|
const cmd = create(Command_ForceActivateUserSchema, { usernameToActivate, moderatorName });
|
||||||
WebClient.instance.protobuf.sendModeratorCommand(Data.Command_ForceActivateUser_ext, cmd, {
|
WebClient.instance.protobuf.sendModeratorCommand(Command_ForceActivateUser_ext, cmd, {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
WebClient.instance.response.moderator.forceActivateUser(usernameToActivate, moderatorName);
|
WebClient.instance.response.moderator.forceActivateUser(usernameToActivate, moderatorName);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
import { create } from '@bufbuild/protobuf';
|
import { create } from '@bufbuild/protobuf';
|
||||||
import { WebClient } from '../../WebClient';
|
import { WebClient } from '../../WebClient';
|
||||||
|
|
||||||
import { Data } from '@app/types';
|
import { Command_GetAdminNotes_ext, Command_GetAdminNotesSchema, Response_GetAdminNotes_ext } from '@app/generated';
|
||||||
|
|
||||||
export function getAdminNotes(userName: string): void {
|
export function getAdminNotes(userName: string): void {
|
||||||
WebClient.instance.protobuf.sendModeratorCommand(Data.Command_GetAdminNotes_ext, create(Data.Command_GetAdminNotesSchema, { userName }), {
|
WebClient.instance.protobuf.sendModeratorCommand(Command_GetAdminNotes_ext, create(Command_GetAdminNotesSchema, { userName }), {
|
||||||
responseExt: Data.Response_GetAdminNotes_ext,
|
responseExt: Response_GetAdminNotes_ext,
|
||||||
onSuccess: (response) => {
|
onSuccess: (response) => {
|
||||||
WebClient.instance.response.moderator.getAdminNotes(userName, response.notes);
|
WebClient.instance.response.moderator.getAdminNotes(userName, response.notes);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
import { create } from '@bufbuild/protobuf';
|
import { create } from '@bufbuild/protobuf';
|
||||||
import { WebClient } from '../../WebClient';
|
import { WebClient } from '../../WebClient';
|
||||||
|
|
||||||
import { Data } from '@app/types';
|
import { Command_GetBanHistory_ext, Command_GetBanHistorySchema, Response_BanHistory_ext } from '@app/generated';
|
||||||
|
|
||||||
export function getBanHistory(userName: string): void {
|
export function getBanHistory(userName: string): void {
|
||||||
WebClient.instance.protobuf.sendModeratorCommand(Data.Command_GetBanHistory_ext, create(Data.Command_GetBanHistorySchema, { userName }), {
|
WebClient.instance.protobuf.sendModeratorCommand(Command_GetBanHistory_ext, create(Command_GetBanHistorySchema, { userName }), {
|
||||||
responseExt: Data.Response_BanHistory_ext,
|
responseExt: Response_BanHistory_ext,
|
||||||
onSuccess: (response) => {
|
onSuccess: (response) => {
|
||||||
WebClient.instance.response.moderator.banHistory(userName, response.banList);
|
WebClient.instance.response.moderator.banHistory(userName, response.banList);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,14 @@
|
||||||
import { create } from '@bufbuild/protobuf';
|
import { create } from '@bufbuild/protobuf';
|
||||||
import { WebClient } from '../../WebClient';
|
import { WebClient } from '../../WebClient';
|
||||||
|
|
||||||
import { Data } from '@app/types';
|
import { Command_GetWarnHistory_ext, Command_GetWarnHistorySchema, Response_WarnHistory_ext } from '@app/generated';
|
||||||
|
|
||||||
export function getWarnHistory(userName: string): void {
|
export function getWarnHistory(userName: string): void {
|
||||||
WebClient.instance.protobuf.sendModeratorCommand(
|
WebClient.instance.protobuf.sendModeratorCommand(
|
||||||
Data.Command_GetWarnHistory_ext,
|
Command_GetWarnHistory_ext,
|
||||||
create(Data.Command_GetWarnHistorySchema, { userName }),
|
create(Command_GetWarnHistorySchema, { userName }),
|
||||||
{
|
{
|
||||||
responseExt: Data.Response_WarnHistory_ext,
|
responseExt: Response_WarnHistory_ext,
|
||||||
onSuccess: (response) => {
|
onSuccess: (response) => {
|
||||||
WebClient.instance.response.moderator.warnHistory(userName, response.warnList);
|
WebClient.instance.response.moderator.warnHistory(userName, response.warnList);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,14 @@
|
||||||
import { create } from '@bufbuild/protobuf';
|
import { create } from '@bufbuild/protobuf';
|
||||||
import { WebClient } from '../../WebClient';
|
import { WebClient } from '../../WebClient';
|
||||||
|
|
||||||
import { Data } from '@app/types';
|
import { Command_GetWarnList_ext, Command_GetWarnListSchema, Response_WarnList_ext } from '@app/generated';
|
||||||
|
|
||||||
export function getWarnList(modName: string, userName: string, userClientid: string): void {
|
export function getWarnList(modName: string, userName: string, userClientid: string): void {
|
||||||
WebClient.instance.protobuf.sendModeratorCommand(
|
WebClient.instance.protobuf.sendModeratorCommand(
|
||||||
Data.Command_GetWarnList_ext,
|
Command_GetWarnList_ext,
|
||||||
create(Data.Command_GetWarnListSchema, { modName, userName, userClientid }),
|
create(Command_GetWarnListSchema, { modName, userName, userClientid }),
|
||||||
{
|
{
|
||||||
responseExt: Data.Response_WarnList_ext,
|
responseExt: Response_WarnList_ext,
|
||||||
onSuccess: (response) => {
|
onSuccess: (response) => {
|
||||||
WebClient.instance.response.moderator.warnListOptions([response]);
|
WebClient.instance.response.moderator.warnListOptions([response]);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
import { create } from '@bufbuild/protobuf';
|
import { create } from '@bufbuild/protobuf';
|
||||||
import { WebClient } from '../../WebClient';
|
import { WebClient } from '../../WebClient';
|
||||||
|
|
||||||
import { Data } from '@app/types';
|
import { Command_GrantReplayAccess_ext, Command_GrantReplayAccessSchema } from '@app/generated';
|
||||||
|
|
||||||
export function grantReplayAccess(replayId: number, moderatorName: string): void {
|
export function grantReplayAccess(replayId: number, moderatorName: string): void {
|
||||||
WebClient.instance.protobuf.sendModeratorCommand(
|
WebClient.instance.protobuf.sendModeratorCommand(
|
||||||
Data.Command_GrantReplayAccess_ext,
|
Command_GrantReplayAccess_ext,
|
||||||
create(Data.Command_GrantReplayAccessSchema, { replayId, moderatorName }),
|
create(Command_GrantReplayAccessSchema, { replayId, moderatorName }),
|
||||||
{
|
{
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
WebClient.instance.response.moderator.grantReplayAccess(replayId, moderatorName);
|
WebClient.instance.response.moderator.grantReplayAccess(replayId, moderatorName);
|
||||||
|
|
|
||||||
|
|
@ -1,28 +1,25 @@
|
||||||
vi.mock('../../WebClient', () => ({
|
vi.mock('../../WebClient');
|
||||||
WebClient: {
|
|
||||||
instance: {
|
|
||||||
protobuf: { sendModeratorCommand: vi.fn() },
|
|
||||||
response: {
|
|
||||||
moderator: {
|
|
||||||
banFromServer: vi.fn(),
|
|
||||||
forceActivateUser: vi.fn(),
|
|
||||||
getAdminNotes: vi.fn(),
|
|
||||||
banHistory: vi.fn(),
|
|
||||||
warnHistory: vi.fn(),
|
|
||||||
warnListOptions: vi.fn(),
|
|
||||||
grantReplayAccess: vi.fn(),
|
|
||||||
updateAdminNotes: vi.fn(),
|
|
||||||
viewLogs: vi.fn(),
|
|
||||||
warnUser: vi.fn(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { makeCallbackHelpers } from '../../__mocks__/callbackHelpers';
|
import { makeCallbackHelpers } from '../../__mocks__/callbackHelpers';
|
||||||
import { WebClient } from '../../WebClient';
|
import { WebClient } from '../../WebClient';
|
||||||
import { Data } from '@app/types';
|
import {
|
||||||
|
Command_BanFromServer_ext,
|
||||||
|
Command_ForceActivateUser_ext,
|
||||||
|
Command_GetAdminNotes_ext,
|
||||||
|
Command_GetBanHistory_ext,
|
||||||
|
Command_GetWarnHistory_ext,
|
||||||
|
Command_GetWarnList_ext,
|
||||||
|
Command_GrantReplayAccess_ext,
|
||||||
|
Command_UpdateAdminNotes_ext,
|
||||||
|
Command_ViewLogHistory_ext,
|
||||||
|
Command_ViewLogHistorySchema,
|
||||||
|
Command_WarnUser_ext,
|
||||||
|
Response_BanHistory_ext,
|
||||||
|
Response_GetAdminNotes_ext,
|
||||||
|
Response_ViewLogHistory_ext,
|
||||||
|
Response_WarnHistory_ext,
|
||||||
|
Response_WarnList_ext,
|
||||||
|
} from '@app/generated';
|
||||||
|
|
||||||
import { banFromServer } from './banFromServer';
|
import { banFromServer } from './banFromServer';
|
||||||
import { forceActivateUser } from './forceActivateUser';
|
import { forceActivateUser } from './forceActivateUser';
|
||||||
|
|
@ -50,7 +47,7 @@ describe('banFromServer', () => {
|
||||||
it('calls sendModeratorCommand with Command_BanFromServer', () => {
|
it('calls sendModeratorCommand with Command_BanFromServer', () => {
|
||||||
banFromServer(30, 'alice', '1.2.3.4', 'reason', 'visible', 'cid', 1);
|
banFromServer(30, 'alice', '1.2.3.4', 'reason', 'visible', 'cid', 1);
|
||||||
expect(WebClient.instance.protobuf.sendModeratorCommand).toHaveBeenCalledWith(
|
expect(WebClient.instance.protobuf.sendModeratorCommand).toHaveBeenCalledWith(
|
||||||
Data.Command_BanFromServer_ext,
|
Command_BanFromServer_ext,
|
||||||
expect.objectContaining({ minutes: 30, userName: 'alice' }),
|
expect.objectContaining({ minutes: 30, userName: 'alice' }),
|
||||||
expect.any(Object)
|
expect.any(Object)
|
||||||
);
|
);
|
||||||
|
|
@ -71,7 +68,7 @@ describe('forceActivateUser', () => {
|
||||||
it('calls sendModeratorCommand with Command_ForceActivateUser', () => {
|
it('calls sendModeratorCommand with Command_ForceActivateUser', () => {
|
||||||
forceActivateUser('alice', 'mod1');
|
forceActivateUser('alice', 'mod1');
|
||||||
expect(WebClient.instance.protobuf.sendModeratorCommand).toHaveBeenCalledWith(
|
expect(WebClient.instance.protobuf.sendModeratorCommand).toHaveBeenCalledWith(
|
||||||
Data.Command_ForceActivateUser_ext, expect.any(Object), expect.any(Object)
|
Command_ForceActivateUser_ext, expect.any(Object), expect.any(Object)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -90,9 +87,9 @@ describe('getAdminNotes', () => {
|
||||||
it('calls sendModeratorCommand with Command_GetAdminNotes', () => {
|
it('calls sendModeratorCommand with Command_GetAdminNotes', () => {
|
||||||
getAdminNotes('alice');
|
getAdminNotes('alice');
|
||||||
expect(WebClient.instance.protobuf.sendModeratorCommand).toHaveBeenCalledWith(
|
expect(WebClient.instance.protobuf.sendModeratorCommand).toHaveBeenCalledWith(
|
||||||
Data.Command_GetAdminNotes_ext,
|
Command_GetAdminNotes_ext,
|
||||||
expect.any(Object),
|
expect.any(Object),
|
||||||
expect.objectContaining({ responseExt: Data.Response_GetAdminNotes_ext })
|
expect.objectContaining({ responseExt: Response_GetAdminNotes_ext })
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -112,9 +109,9 @@ describe('getBanHistory', () => {
|
||||||
it('calls sendModeratorCommand with Command_GetBanHistory', () => {
|
it('calls sendModeratorCommand with Command_GetBanHistory', () => {
|
||||||
getBanHistory('alice');
|
getBanHistory('alice');
|
||||||
expect(WebClient.instance.protobuf.sendModeratorCommand).toHaveBeenCalledWith(
|
expect(WebClient.instance.protobuf.sendModeratorCommand).toHaveBeenCalledWith(
|
||||||
Data.Command_GetBanHistory_ext,
|
Command_GetBanHistory_ext,
|
||||||
expect.any(Object),
|
expect.any(Object),
|
||||||
expect.objectContaining({ responseExt: Data.Response_BanHistory_ext })
|
expect.objectContaining({ responseExt: Response_BanHistory_ext })
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -134,9 +131,9 @@ describe('getWarnHistory', () => {
|
||||||
it('calls sendModeratorCommand with Command_GetWarnHistory', () => {
|
it('calls sendModeratorCommand with Command_GetWarnHistory', () => {
|
||||||
getWarnHistory('alice');
|
getWarnHistory('alice');
|
||||||
expect(WebClient.instance.protobuf.sendModeratorCommand).toHaveBeenCalledWith(
|
expect(WebClient.instance.protobuf.sendModeratorCommand).toHaveBeenCalledWith(
|
||||||
Data.Command_GetWarnHistory_ext,
|
Command_GetWarnHistory_ext,
|
||||||
expect.any(Object),
|
expect.any(Object),
|
||||||
expect.objectContaining({ responseExt: Data.Response_WarnHistory_ext })
|
expect.objectContaining({ responseExt: Response_WarnHistory_ext })
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -156,9 +153,9 @@ describe('getWarnList', () => {
|
||||||
it('calls sendModeratorCommand with Command_GetWarnList', () => {
|
it('calls sendModeratorCommand with Command_GetWarnList', () => {
|
||||||
getWarnList('mod1', 'alice', 'US');
|
getWarnList('mod1', 'alice', 'US');
|
||||||
expect(WebClient.instance.protobuf.sendModeratorCommand).toHaveBeenCalledWith(
|
expect(WebClient.instance.protobuf.sendModeratorCommand).toHaveBeenCalledWith(
|
||||||
Data.Command_GetWarnList_ext,
|
Command_GetWarnList_ext,
|
||||||
expect.any(Object),
|
expect.any(Object),
|
||||||
expect.objectContaining({ responseExt: Data.Response_WarnList_ext })
|
expect.objectContaining({ responseExt: Response_WarnList_ext })
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -178,7 +175,7 @@ describe('grantReplayAccess', () => {
|
||||||
it('calls sendModeratorCommand with Command_GrantReplayAccess', () => {
|
it('calls sendModeratorCommand with Command_GrantReplayAccess', () => {
|
||||||
grantReplayAccess(10, 'mod1');
|
grantReplayAccess(10, 'mod1');
|
||||||
expect(WebClient.instance.protobuf.sendModeratorCommand).toHaveBeenCalledWith(
|
expect(WebClient.instance.protobuf.sendModeratorCommand).toHaveBeenCalledWith(
|
||||||
Data.Command_GrantReplayAccess_ext, expect.any(Object), expect.any(Object)
|
Command_GrantReplayAccess_ext, expect.any(Object), expect.any(Object)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -197,7 +194,7 @@ describe('updateAdminNotes', () => {
|
||||||
it('calls sendModeratorCommand with Command_UpdateAdminNotes', () => {
|
it('calls sendModeratorCommand with Command_UpdateAdminNotes', () => {
|
||||||
updateAdminNotes('alice', 'new notes');
|
updateAdminNotes('alice', 'new notes');
|
||||||
expect(WebClient.instance.protobuf.sendModeratorCommand).toHaveBeenCalledWith(
|
expect(WebClient.instance.protobuf.sendModeratorCommand).toHaveBeenCalledWith(
|
||||||
Data.Command_UpdateAdminNotes_ext, expect.any(Object), expect.any(Object)
|
Command_UpdateAdminNotes_ext, expect.any(Object), expect.any(Object)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -214,17 +211,17 @@ describe('updateAdminNotes', () => {
|
||||||
describe('viewLogHistory', () => {
|
describe('viewLogHistory', () => {
|
||||||
|
|
||||||
it('calls sendModeratorCommand with Command_ViewLogHistory', () => {
|
it('calls sendModeratorCommand with Command_ViewLogHistory', () => {
|
||||||
const filters = create(Data.Command_ViewLogHistorySchema, { dateRange: 7 });
|
const filters = create(Command_ViewLogHistorySchema, { dateRange: 7 });
|
||||||
viewLogHistory(filters);
|
viewLogHistory(filters);
|
||||||
expect(WebClient.instance.protobuf.sendModeratorCommand).toHaveBeenCalledWith(
|
expect(WebClient.instance.protobuf.sendModeratorCommand).toHaveBeenCalledWith(
|
||||||
Data.Command_ViewLogHistory_ext,
|
Command_ViewLogHistory_ext,
|
||||||
expect.any(Object),
|
expect.any(Object),
|
||||||
expect.objectContaining({ responseExt: Data.Response_ViewLogHistory_ext })
|
expect.objectContaining({ responseExt: Response_ViewLogHistory_ext })
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('onSuccess calls response.moderator.viewLogs with logMessage', () => {
|
it('onSuccess calls response.moderator.viewLogs with logMessage', () => {
|
||||||
const filters = create(Data.Command_ViewLogHistorySchema, { dateRange: 7 });
|
const filters = create(Command_ViewLogHistorySchema, { dateRange: 7 });
|
||||||
viewLogHistory(filters);
|
viewLogHistory(filters);
|
||||||
const resp = { logMessage: ['log1'] };
|
const resp = { logMessage: ['log1'] };
|
||||||
invokeOnSuccess(resp, { responseCode: 0 });
|
invokeOnSuccess(resp, { responseCode: 0 });
|
||||||
|
|
@ -240,7 +237,7 @@ describe('warnUser', () => {
|
||||||
it('calls sendModeratorCommand with Command_WarnUser', () => {
|
it('calls sendModeratorCommand with Command_WarnUser', () => {
|
||||||
warnUser('alice', 'bad behavior', 'cid');
|
warnUser('alice', 'bad behavior', 'cid');
|
||||||
expect(WebClient.instance.protobuf.sendModeratorCommand).toHaveBeenCalledWith(
|
expect(WebClient.instance.protobuf.sendModeratorCommand).toHaveBeenCalledWith(
|
||||||
Data.Command_WarnUser_ext, expect.any(Object), expect.any(Object)
|
Command_WarnUser_ext, expect.any(Object), expect.any(Object)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
import { create } from '@bufbuild/protobuf';
|
import { create } from '@bufbuild/protobuf';
|
||||||
import { WebClient } from '../../WebClient';
|
import { WebClient } from '../../WebClient';
|
||||||
|
|
||||||
import { Data } from '@app/types';
|
import { Command_UpdateAdminNotes_ext, Command_UpdateAdminNotesSchema } from '@app/generated';
|
||||||
|
|
||||||
export function updateAdminNotes(userName: string, notes: string): void {
|
export function updateAdminNotes(userName: string, notes: string): void {
|
||||||
WebClient.instance.protobuf.sendModeratorCommand(
|
WebClient.instance.protobuf.sendModeratorCommand(
|
||||||
Data.Command_UpdateAdminNotes_ext,
|
Command_UpdateAdminNotes_ext,
|
||||||
create(Data.Command_UpdateAdminNotesSchema, { userName, notes }),
|
create(Command_UpdateAdminNotesSchema, { userName, notes }),
|
||||||
{
|
{
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
WebClient.instance.response.moderator.updateAdminNotes(userName, notes);
|
WebClient.instance.response.moderator.updateAdminNotes(userName, notes);
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
import { create } from '@bufbuild/protobuf';
|
import { create } from '@bufbuild/protobuf';
|
||||||
import { WebClient } from '../../WebClient';
|
import { WebClient } from '../../WebClient';
|
||||||
|
|
||||||
import { Data } from '@app/types';
|
import { Command_ViewLogHistory_ext, Command_ViewLogHistorySchema, Response_ViewLogHistory_ext } from '@app/generated';
|
||||||
|
import type { ViewLogHistoryParams } from '@app/generated';
|
||||||
|
|
||||||
export function viewLogHistory(filters: Data.ViewLogHistoryParams): void {
|
export function viewLogHistory(filters: ViewLogHistoryParams): void {
|
||||||
WebClient.instance.protobuf.sendModeratorCommand(Data.Command_ViewLogHistory_ext, create(Data.Command_ViewLogHistorySchema, filters), {
|
WebClient.instance.protobuf.sendModeratorCommand(Command_ViewLogHistory_ext, create(Command_ViewLogHistorySchema, filters), {
|
||||||
responseExt: Data.Response_ViewLogHistory_ext,
|
responseExt: Response_ViewLogHistory_ext,
|
||||||
onSuccess: (response) => {
|
onSuccess: (response) => {
|
||||||
WebClient.instance.response.moderator.viewLogs(response.logMessage);
|
WebClient.instance.response.moderator.viewLogs(response.logMessage);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
import { create } from '@bufbuild/protobuf';
|
import { create } from '@bufbuild/protobuf';
|
||||||
import { WebClient } from '../../WebClient';
|
import { WebClient } from '../../WebClient';
|
||||||
|
|
||||||
import { Data } from '@app/types';
|
import { Command_WarnUser_ext, Command_WarnUserSchema } from '@app/generated';
|
||||||
|
|
||||||
export function warnUser(userName: string, reason: string, clientid?: string, removeMessages?: number): void {
|
export function warnUser(userName: string, reason: string, clientid?: string, removeMessages?: number): void {
|
||||||
const cmd = create(Data.Command_WarnUserSchema, { userName, reason, clientid, removeMessages });
|
const cmd = create(Command_WarnUserSchema, { userName, reason, clientid, removeMessages });
|
||||||
WebClient.instance.protobuf.sendModeratorCommand(Data.Command_WarnUser_ext, cmd, {
|
WebClient.instance.protobuf.sendModeratorCommand(Command_WarnUser_ext, cmd, {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
WebClient.instance.response.moderator.warnUser(userName);
|
WebClient.instance.response.moderator.warnUser(userName);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
import { create } from '@bufbuild/protobuf';
|
import { create } from '@bufbuild/protobuf';
|
||||||
import { WebClient } from '../../WebClient';
|
import { WebClient } from '../../WebClient';
|
||||||
|
|
||||||
import { Data } from '@app/types';
|
import { Command_CreateGame_ext, Command_CreateGameSchema } from '@app/generated';
|
||||||
|
import type { CreateGameParams } from '@app/generated';
|
||||||
|
|
||||||
export function createGame(roomId: number, gameConfig: Data.CreateGameParams): void {
|
export function createGame(roomId: number, gameConfig: CreateGameParams): void {
|
||||||
WebClient.instance.protobuf.sendRoomCommand(roomId, Data.Command_CreateGame_ext, create(Data.Command_CreateGameSchema, gameConfig), {
|
WebClient.instance.protobuf.sendRoomCommand(roomId, Command_CreateGame_ext, create(Command_CreateGameSchema, gameConfig), {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
WebClient.instance.response.room.gameCreated(roomId);
|
WebClient.instance.response.room.gameCreated(roomId);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue