refactor typescript wiring

This commit is contained in:
seavor 2026-04-15 15:46:17 -05:00
parent cea9ae62d8
commit c62c336a11
286 changed files with 2999 additions and 3053 deletions

View file

@ -5,6 +5,9 @@
/.pnp /.pnp
.pnp.js .pnp.js
# AI generated docs
/plans
# generated ./src files # generated ./src files
/src/proto-files.json /src/proto-files.json
/src/server-props.json /src/server-props.json

170
webclient/CLAUDE.md Normal file
View file

@ -0,0 +1,170 @@
# CLAUDE.md
Guidance for Claude Code when working inside `webclient/` — the React/TypeScript SPA (Webatrice) that connects to a Servatrice server over a WebSocket. It is a self-contained application; the only thing it shares with the rest of the repo (C++ desktop/server stack) is the protobuf protocol in `../libcockatrice_protocol/`. Anything outside `webclient/` is out-of-scope unless a task explicitly touches the protocol.
All commands below are run from this directory.
## Commands
```bash
npm start # Vite dev server (runs proto:generate + prebuild.js first)
npm run build # production build (same prebuild hooks)
npm test # vitest run (one-shot)
npm run test:watch # vitest watch
npm run lint # eslint src/
npm run lint:fix
npm run golden # lint + test — the CI-equivalent gate to run before declaring work done
npm run proto:generate # `npx buf generate` — regenerates TS bindings into src/generated/proto
```
Single test file: `npx vitest run path/to/file.spec.ts`. Filter by name: `npx vitest run -t "partial test name"`.
The dev server has `server.open: true`, so `npm start` pops a browser tab automatically.
## Architecture
The webclient is a Redux Toolkit + RxJS app. Its defining abstraction is a layered WebSocket client that speaks the Cockatrice protobuf protocol to Servatrice. Understanding the layering is essential before editing anything under `src/websocket/`, `src/api/`, or `src/store/`.
### Protocol layer
- **`src/generated/proto/`** — auto-generated from `../libcockatrice_protocol/**/*.proto` by `buf` (see `buf.gen.yaml`). Never edit by hand. Runtime is `@bufbuild/protobuf` (Protobuf-ES); the codebase was recently migrated off the older `protobufjs`, so if you find any stray references to the old runtime, they're bugs.
- **`src/types/` is the only public surface for generated code.** `src/types/data.ts` hand-rolls an `export *` barrel over every proto file that consumers use, and `src/types/index.ts` re-exports it as `Data`, plus `Enriched` (protocol types extended with client-only fields) and `App` (pure client types). Import as `import { Data, Enriched } from '@app/types'` and use `Data.Command_Login`, `Data.ServerInfo_User`, etc. **Never import directly from `@app/generated/proto/*` outside `src/types/`.** When a new proto file starts being consumed, add an `export *` line to `src/types/data.ts` — there is a standing TODO to replace this rollup with a protobuf-es plugin.
### WebSocket layer (`src/websocket/`)
A strict inbound/outbound split sits on top of a transport core:
- **`services/`** — transport: `WebSocketService` (socket lifecycle), `ProtobufService` (encode/decode + request/response correlation), `KeepAliveService` (ping/pong), `command-options` (per-command response config). This layer has no knowledge of Redux.
- **`commands/`** — *outbound*. Organised by scope (`session/`, `room/`, `game/`, `admin/`, `moderator/`). Each command builds a protobuf request and hands it to `ProtobufService.send{Session,Room,Game,Admin,Moderator}Command` along with a `CommandOptions` describing how to handle the response.
- **`events/`** — *inbound*. Handlers for server-pushed events, same scopes. They translate protobuf events into calls on the persistence layer.
- **`persistence/`** — the **only** bridge from the websocket layer into app state. `SessionPersistence`, `RoomPersistence`, `GamePersistence`, `AdminPersistence`, `ModeratorPersistence` dispatch Redux actions and/or write to Dexie.
- **`WebClient.ts`** — singleton facade that wires the services, commands, events, and persistence together.
**Layering invariant (enforced on this branch, not aspirational):**
1. Containers and components call `src/api/*` services — never `src/websocket/*` directly.
2. Commands and event handlers call `*Persistence` methods — never `store.dispatch` directly.
3. Only `*.dispatch.ts` helpers inside `src/store/` and persistence code may touch the Redux store.
If you find yourself wanting to skip a layer (dispatching from an event handler, calling a command from a container, reaching into `src/generated/proto/` from a component), stop — the refactor on `webclient-websocket-layer` exists precisely to eliminate those shortcuts. There are currently zero violations; keep it that way.
### ProtobufService: request/response correlation
- Every outbound `CommandContainer` gets a monotonically increasing `cmdId` (cast to `BigInt` for the proto field). A `Map<number, callback>` stores the response handler keyed by that ID; when `ServerMessage.RESPONSE` arrives, `processServerResponse` looks up and invokes the callback, then deletes the entry.
- There is **no timeout or retry**. `resetCommands()` (called on reconnect) zeros `cmdId` and clears the pending map, silently dropping any in-flight callbacks. Code that needs reconnection resilience has to handle it at a higher layer.
- `sendCommand` is a no-op write if the transport isn't open — it still registers the callback, so a stale pending entry can accumulate until the next reset.
- Inbound event dispatch is extension-based: `processRoomEvent` / `processSessionEvent` / `processGameEvent` iterate `RoomEvents` / `SessionEvents` / `GameEvents` (tuples of `[extension, handler]`) and invoke the first handler whose extension is set on the message. Adding a new event handler means appending to those arrays.
### command-options contract (`src/websocket/services/command-options.ts`)
Every `send*Command` call accepts an optional `CommandOptions<R>`:
- `responseExt?: GenExtension<Response, R>` — the response payload extension to unwrap on success.
- `onSuccess?: (response: R, raw: Response) => void` — called when `responseCode === RespOk`. If `responseExt` is absent, the overload becomes `() => void`.
- `onResponseCode?: { [code: number]: (raw: Response) => void }` — per-error-code handlers.
- `onError?: (code: number, raw: Response) => void` — fallback for codes not in `onResponseCode`.
- `onResponse?: (raw: Response) => void` — if set, it handles the raw response and bypasses every other hook. Use this when you need the full response object regardless of code.
If none of the hooks fire for a non-OK response, `handleResponse` logs the failure via `console.error` with the command's proto type name. The practical rule: `onSuccess` funnels into persistence, `onError` funnels into persistence (usually to flip connection state or show a toast), and `onResponse` is rare.
### Public API for UI (`src/api/`)
Thin service wrappers (`AuthenticationService`, `SessionService`, `RoomsService`, `GameService`, `ModeratorService`, `AdminService`) that expose websocket commands to UI code. A few things to know:
- **All command methods are `static` and return `void`.** They're fire-and-forget — the response flows back through the `command-options` callbacks plumbed inside the command itself, into persistence, into the store. Don't try to await them.
- A handful of methods return `boolean` (e.g. `AuthenticationService.isConnected`, `isModerator`) — those are pure sync predicates, not command sends.
- Files use the `.tsx` extension even though they contain no JSX. That's a leftover convention; don't "fix" it.
### State (`src/store/`)
Redux Toolkit store (`store.ts`, `rootReducer.ts`) split by feature. Each slice follows the same file layout:
- `*.actions.ts` — action creators
- `*.reducer.ts` — slice reducer
- `*.selectors.ts` — selectors (mostly plain getters; `createSelector` only for derived lists)
- `*.dispatch.ts` — dispatch helpers called by the persistence layer
- `*.interfaces.ts` / `*.types.ts` — state shape and enums
Slices: `server/`, `rooms/`, `game/`, plus shared `actions/` and `common/` helpers (`SortUtil`, `normalizers`). Consumers import through the `@app/store` barrel — `GameSelectors`, `GameDispatch`, `GameTypes`, and the same prefixed set for `Server`/`Rooms`. **Don't deep-import from `src/store/game/game.selectors.ts` etc.** — go through `@app/store`.
Shape notes worth knowing before you touch a reducer:
- `game/` is deeply normalized: `games[gameId].players[playerId].zones[zoneName].cards`. Selectors are plain getters so lookups stay O(1); `createSelector` is reserved for the few that build derived lists (e.g. `getActiveGameIds`).
- Selectors return module-scope `EMPTY_ARRAY` / `EMPTY_OBJECT` constants for missing data to preserve referential equality and avoid spurious re-renders.
- `rooms/` is *partially* normalized: rooms are keyed by ID, but each room also carries denormalized `gameList` / `userList` arrays. Server updates often omit those lists, so the reducer merges new metadata while preserving the existing arrays. There is a standing TODO to clean this up.
- `server/` is mostly flat maps keyed by username (`messages`, `userInfo`, buddy/ignore lists) plus connection state.
### Local persistence (`src/services/dexie/`)
IndexedDB storage via Dexie for cards, sets, tokens, known hosts, and settings. DTOs live in `DexieDTOs/`. This is separate from the Redux store — used for data that should survive a reload (card database, user settings, host list). Dexie is not mocked in unit tests; code that writes to Dexie is typically exercised only in integration paths.
### UI
- **`containers/`** — route-level, Redux-connected. Top-level routes: `App`, `Initialize`, `Login`, `Server`, `Room`, `Game`, `Player`, `Decks`, `Account`, `Logs`, `Layout`, `Unsupported`. Routing lives in `containers/App/AppShellRoutes.tsx`.
- **`components/`** — presentational, mostly unconnected.
- **`forms/`** — `react-final-form` forms (e.g. `LoginForm`).
- **`dialogs/`** — MUI dialogs.
- **`hooks/`** — shared hooks (e.g. `useAutoConnect`).
- **`i18n.ts` / `i18n-backend.ts`** — `react-i18next` + ICU; translations managed via Transifex.
- UI kit: MUI v7 (`@mui/material`, `@emotion`).
### Path aliases
`tsconfig.json` defines the following (resolved at build time by `vite-tsconfig-paths`):
```
@app/api @app/components @app/containers @app/dialogs
@app/forms @app/hooks @app/images @app/services
@app/store @app/types @app/websocket @app/generated/*
```
Prefer these in new code over relative imports when crossing top-level directory boundaries. Deep paths into a barrel target (e.g. `@app/store/game/...`) are a smell — add the symbol to the relevant `index.ts` barrel instead.
### End-to-end data flow
User action in a container → `src/api/*Service``src/websocket/commands/*``ProtobufService.send*Command` → socket.
Server reply/event → `src/websocket/events/*` (or the `command-options` callback on the original command) → `src/websocket/persistence/*``*.dispatch.ts` helpers → Redux / Dexie → selectors → container re-render.
## Build pipeline and generated files
`npm start` and `npm run build` both run `prestart`/`prebuild` hooks that invoke `proto:generate` and then `node prebuild.js`. `prebuild.js` does three things:
1. Copies shared country flag assets from `../cockatrice/resources/countries` into `src/images/countries`.
2. Writes `src/server-props.json` containing `REACT_APP_VERSION` = current `git rev-parse HEAD`.
3. Walks `src/**/*.i18n.json`, merges them into `src/i18n-default.json`, and **throws on duplicate keys** (`i18n key collision: ${key}`). Namespace your i18n keys — collisions fail the build.
Files you should never edit by hand (all auto-generated, all committed):
- `src/generated/proto/**`
- `src/i18n-default.json`
- `src/server-props.json`
If `npm start` seems to be ignoring a new `.i18n.json` file or a fresh proto, run `npm run proto:generate && node prebuild.js` directly — the hooks only fire on `start`/`build`, not on `test` or `lint`.
`.env.development`, `.env.production`, and `.env.test` exist but are empty. There is currently no env-var configuration surface; server URLs and the like are resolved through the login UI / `server-props.json`, not `import.meta.env`.
## Testing
Vitest + Testing Library + jsdom; `setupTests.ts` registers jest-dom matchers.
**Vitest runs with `test.isolate: false`.** Every spec file in a worker shares the same module graph, so `vi.mock(...)` factories and any mocks they create persist across tests. Consequences:
- The global `afterEach` in `setupTests.ts` calls `vi.clearAllMocks()` + `vi.restoreAllMocks()` + `vi.useRealTimers()`. It deliberately does **not** call `vi.resetAllMocks()`, because that would reset the implementations of `vi.fn()` instances created inside `vi.mock(...)` factories and break every spec that mocks `store.dispatch` once at file load.
- A test that installs a custom `mockReturnValue` / `mockImplementation` should not assume the next test resets it — either overwrite it or rely on `clearAllMocks` wiping only call histories.
- Always use real timers at the end of a test that switched to fake ones; the global teardown will catch leaks, but relying on it is fragile across files.
Other conventions:
- **Fixtures.** Store slices have co-located `__mocks__/fixtures.ts` files (notably `src/store/game/__mocks__/fixtures.ts`) exposing factories like `makeCard`, `makeGameEntry`, `makePlayerProperties`, `makeState`. They build protobuf messages via `create(Schema, overrides)`. Reuse them in new tests instead of hand-rolling proto objects.
- **Websocket mocks.** `src/websocket/__mocks__/` holds shared mock builders (e.g. `makeMockWebSocket`, `makeWebClientMock`, `makeSessionPersistenceMock`). Command and event specs install these with `vi.mock(...)` at the top of the file.
- **Slice tests are per-concern.** Each slice ships parallel `*.actions.spec.ts`, `*.reducer.spec.ts`, `*.selectors.spec.ts`, and `*.dispatch.spec.ts` files; tests don't cross concerns.
`npm run golden` (lint + test) is the CI gate — run it before declaring work done.
## Protocol changes
When a task requires editing `.proto` files in `../libcockatrice_protocol/`, run `npm run proto:generate` afterwards, and:
1. If the change introduces a new proto *file* that code outside `src/types/` needs to consume, add an `export *` line for it in `src/types/data.ts`.
2. Update any command/event/persistence code that consumes the changed messages.
3. Commit the regenerated files under `src/generated/proto/`.

View file

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

View file

@ -6,3 +6,7 @@ plugins:
out: src/generated/proto out: src/generated/proto
opt: opt:
- target=ts - target=ts
- local: [node, buf.gen.plugin.mjs]
out: src/generated
opt:
- target=ts

View file

@ -1,4 +1,4 @@
vi.mock('websocket', () => ({ vi.mock('@app/websocket', () => ({
AdminCommands: { AdminCommands: {
adjustMod: vi.fn(), adjustMod: vi.fn(),
reloadConfig: vi.fn(), reloadConfig: vi.fn(),
@ -8,9 +8,7 @@ vi.mock('websocket', () => ({
})); }));
import { AdminService } from './AdminService'; import { AdminService } from './AdminService';
import { AdminCommands } from 'websocket'; import { AdminCommands } from '@app/websocket';
beforeEach(() => vi.clearAllMocks());
describe('AdminService', () => { describe('AdminService', () => {
describe('adjustMod', () => { describe('adjustMod', () => {

View file

@ -1,4 +1,4 @@
import { AdminCommands } from 'websocket'; import { AdminCommands } from '@app/websocket';
export class AdminService { export class AdminService {
static adjustMod(userName: string, shouldBeMod?: boolean, shouldBeJudge?: boolean): void { static adjustMod(userName: string, shouldBeMod?: boolean, shouldBeJudge?: boolean): void {

View file

@ -1,71 +1,113 @@
vi.mock('websocket', () => ({ vi.mock('@app/websocket', () => ({
SessionCommands: { SessionCommands: {
connect: vi.fn(), connect: vi.fn(),
disconnect: vi.fn(), disconnect: vi.fn(),
}, },
})); }));
vi.mock('generated/proto/serverinfo_user_pb', () => ({ vi.mock('../generated/proto/serverinfo_user_pb', async (importOriginal) => {
ServerInfo_User_UserLevelFlag: { const actual = await importOriginal();
IsModerator: 4, return {
}, ...actual,
})); ServerInfo_User_UserLevelFlag: {
IsModerator: 4,
},
};
});
import { AuthenticationService } from './AuthenticationService'; import { AuthenticationService } from './AuthenticationService';
import { SessionCommands } from 'websocket'; import { SessionCommands } from '@app/websocket';
import { StatusEnum, WebSocketConnectOptions, WebSocketConnectReason } from 'types'; import { App, Data } from '@app/types';
import { create } from '@bufbuild/protobuf';
const testOptions: WebSocketConnectOptions = { host: 'localhost', port: '4748', userName: 'user', password: 'pw' }; const baseTransport = { host: 'localhost', port: '4748' };
beforeEach(() => vi.clearAllMocks());
describe('AuthenticationService', () => { describe('AuthenticationService', () => {
describe('login', () => { describe('login', () => {
it('calls SessionCommands.connect with LOGIN reason', () => { it('calls SessionCommands.connect with LOGIN reason', () => {
AuthenticationService.login(testOptions); AuthenticationService.login({ ...baseTransport, userName: 'user', password: 'pw' });
expect(SessionCommands.connect).toHaveBeenCalledWith(testOptions, WebSocketConnectReason.LOGIN); expect(SessionCommands.connect).toHaveBeenCalledWith(
expect.objectContaining({
...baseTransport,
userName: 'user',
password: 'pw',
reason: App.WebSocketConnectReason.LOGIN,
})
);
}); });
}); });
describe('testConnection', () => { describe('testConnection', () => {
it('calls SessionCommands.connect with TEST_CONNECTION reason', () => { it('calls SessionCommands.connect with TEST_CONNECTION reason', () => {
AuthenticationService.testConnection(testOptions); AuthenticationService.testConnection(baseTransport);
expect(SessionCommands.connect).toHaveBeenCalledWith(testOptions, WebSocketConnectReason.TEST_CONNECTION); expect(SessionCommands.connect).toHaveBeenCalledWith(
expect.objectContaining({ ...baseTransport, reason: App.WebSocketConnectReason.TEST_CONNECTION })
);
}); });
}); });
describe('register', () => { describe('register', () => {
it('calls SessionCommands.connect with REGISTER reason', () => { it('calls SessionCommands.connect with REGISTER reason', () => {
AuthenticationService.register(testOptions); AuthenticationService.register({
expect(SessionCommands.connect).toHaveBeenCalledWith(testOptions, WebSocketConnectReason.REGISTER); ...baseTransport,
userName: 'user',
password: 'pw',
email: 'a@b.com',
country: 'US',
realName: 'User',
});
expect(SessionCommands.connect).toHaveBeenCalledWith(
expect.objectContaining({ userName: 'user', reason: App.WebSocketConnectReason.REGISTER })
);
}); });
}); });
describe('activateAccount', () => { describe('activateAccount', () => {
it('calls SessionCommands.connect with ACTIVATE_ACCOUNT reason', () => { it('calls SessionCommands.connect with ACTIVATE_ACCOUNT reason', () => {
AuthenticationService.activateAccount(testOptions); AuthenticationService.activateAccount({
expect(SessionCommands.connect).toHaveBeenCalledWith(testOptions, WebSocketConnectReason.ACTIVATE_ACCOUNT); ...baseTransport,
userName: 'user',
token: 'tok',
});
expect(SessionCommands.connect).toHaveBeenCalledWith(
expect.objectContaining({ token: 'tok', reason: App.WebSocketConnectReason.ACTIVATE_ACCOUNT })
);
}); });
}); });
describe('resetPasswordRequest', () => { describe('resetPasswordRequest', () => {
it('calls SessionCommands.connect with PASSWORD_RESET_REQUEST reason', () => { it('calls SessionCommands.connect with PASSWORD_RESET_REQUEST reason', () => {
AuthenticationService.resetPasswordRequest(testOptions); AuthenticationService.resetPasswordRequest({ ...baseTransport, userName: 'user' });
expect(SessionCommands.connect).toHaveBeenCalledWith(testOptions, WebSocketConnectReason.PASSWORD_RESET_REQUEST); expect(SessionCommands.connect).toHaveBeenCalledWith(
expect.objectContaining({ userName: 'user', reason: App.WebSocketConnectReason.PASSWORD_RESET_REQUEST })
);
}); });
}); });
describe('resetPasswordChallenge', () => { describe('resetPasswordChallenge', () => {
it('calls SessionCommands.connect with PASSWORD_RESET_CHALLENGE reason', () => { it('calls SessionCommands.connect with PASSWORD_RESET_CHALLENGE reason', () => {
AuthenticationService.resetPasswordChallenge(testOptions); AuthenticationService.resetPasswordChallenge({
expect(SessionCommands.connect).toHaveBeenCalledWith(testOptions, WebSocketConnectReason.PASSWORD_RESET_CHALLENGE); ...baseTransport,
userName: 'user',
email: 'a@b.com',
});
expect(SessionCommands.connect).toHaveBeenCalledWith(
expect.objectContaining({ email: 'a@b.com', reason: App.WebSocketConnectReason.PASSWORD_RESET_CHALLENGE })
);
}); });
}); });
describe('resetPassword', () => { describe('resetPassword', () => {
it('calls SessionCommands.connect with PASSWORD_RESET reason', () => { it('calls SessionCommands.connect with PASSWORD_RESET reason', () => {
AuthenticationService.resetPassword(testOptions); AuthenticationService.resetPassword({
expect(SessionCommands.connect).toHaveBeenCalledWith(testOptions, WebSocketConnectReason.PASSWORD_RESET); ...baseTransport,
userName: 'user',
token: 'tok',
newPassword: 'newpw',
});
expect(SessionCommands.connect).toHaveBeenCalledWith(
expect.objectContaining({ newPassword: 'newpw', reason: App.WebSocketConnectReason.PASSWORD_RESET })
);
}); });
}); });
@ -78,41 +120,41 @@ describe('AuthenticationService', () => {
describe('isConnected', () => { describe('isConnected', () => {
it('returns true when state is LOGGED_IN', () => { it('returns true when state is LOGGED_IN', () => {
expect(AuthenticationService.isConnected(StatusEnum.LOGGED_IN)).toBe(true); expect(AuthenticationService.isConnected(App.StatusEnum.LOGGED_IN)).toBe(true);
}); });
it('returns false when state is DISCONNECTED', () => { it('returns false when state is DISCONNECTED', () => {
expect(AuthenticationService.isConnected(StatusEnum.DISCONNECTED)).toBe(false); expect(AuthenticationService.isConnected(App.StatusEnum.DISCONNECTED)).toBe(false);
}); });
it('returns false when state is CONNECTING', () => { it('returns false when state is CONNECTING', () => {
expect(AuthenticationService.isConnected(StatusEnum.CONNECTING)).toBe(false); expect(AuthenticationService.isConnected(App.StatusEnum.CONNECTING)).toBe(false);
}); });
it('returns false when state is CONNECTED', () => { it('returns false when state is CONNECTED', () => {
expect(AuthenticationService.isConnected(StatusEnum.CONNECTED)).toBe(false); expect(AuthenticationService.isConnected(App.StatusEnum.CONNECTED)).toBe(false);
}); });
it('returns false when state is LOGGING_IN', () => { it('returns false when state is LOGGING_IN', () => {
expect(AuthenticationService.isConnected(StatusEnum.LOGGING_IN)).toBe(false); expect(AuthenticationService.isConnected(App.StatusEnum.LOGGING_IN)).toBe(false);
}); });
}); });
describe('isModerator', () => { describe('isModerator', () => {
it('returns true when userLevel has the IsModerator bit set', () => { it('returns true when userLevel has the IsModerator bit set', () => {
expect(AuthenticationService.isModerator({ userLevel: 4 } as any)).toBe(true); expect(AuthenticationService.isModerator(create(Data.ServerInfo_UserSchema, { userLevel: 4 }))).toBe(true);
}); });
it('returns true when userLevel has IsModerator and other bits set', () => { it('returns true when userLevel has IsModerator and other bits set', () => {
expect(AuthenticationService.isModerator({ userLevel: 7 } as any)).toBe(true); expect(AuthenticationService.isModerator(create(Data.ServerInfo_UserSchema, { userLevel: 7 }))).toBe(true);
}); });
it('returns false when userLevel does not have the IsModerator bit', () => { it('returns false when userLevel does not have the IsModerator bit', () => {
expect(AuthenticationService.isModerator({ userLevel: 1 } as any)).toBe(false); expect(AuthenticationService.isModerator(create(Data.ServerInfo_UserSchema, { userLevel: 1 }))).toBe(false);
}); });
it('returns false for admin-only userLevel without moderator bit', () => { it('returns false for admin-only userLevel without moderator bit', () => {
expect(AuthenticationService.isModerator({ userLevel: 8 } as any)).toBe(false); expect(AuthenticationService.isModerator(create(Data.ServerInfo_UserSchema, { userLevel: 8 }))).toBe(false);
}); });
}); });

View file

@ -1,34 +1,33 @@
import { StatusEnum, WebSocketConnectReason, WebSocketConnectOptions } from 'types'; import { App, Data, Enriched } from '@app/types';
import { SessionCommands } from 'websocket'; import { SessionCommands } from '@app/websocket';
import { ServerInfo_User, ServerInfo_User_UserLevelFlag } from 'generated/proto/serverinfo_user_pb';
export class AuthenticationService { export class AuthenticationService {
static login(options: WebSocketConnectOptions): void { static login(options: Omit<Enriched.LoginConnectOptions, 'reason'>): void {
SessionCommands.connect(options, WebSocketConnectReason.LOGIN); SessionCommands.connect({ ...options, reason: App.WebSocketConnectReason.LOGIN });
} }
static testConnection(options: WebSocketConnectOptions): void { static testConnection(options: Omit<Enriched.TestConnectionOptions, 'reason'>): void {
SessionCommands.connect(options, WebSocketConnectReason.TEST_CONNECTION); SessionCommands.connect({ ...options, reason: App.WebSocketConnectReason.TEST_CONNECTION });
} }
static register(options: WebSocketConnectOptions): void { static register(options: Omit<Enriched.RegisterConnectOptions, 'reason'>): void {
SessionCommands.connect(options, WebSocketConnectReason.REGISTER); SessionCommands.connect({ ...options, reason: App.WebSocketConnectReason.REGISTER });
} }
static activateAccount(options: WebSocketConnectOptions): void { static activateAccount(options: Omit<Enriched.ActivateConnectOptions, 'reason'>): void {
SessionCommands.connect(options, WebSocketConnectReason.ACTIVATE_ACCOUNT); SessionCommands.connect({ ...options, reason: App.WebSocketConnectReason.ACTIVATE_ACCOUNT });
} }
static resetPasswordRequest(options: WebSocketConnectOptions): void { static resetPasswordRequest(options: Omit<Enriched.PasswordResetRequestConnectOptions, 'reason'>): void {
SessionCommands.connect(options, WebSocketConnectReason.PASSWORD_RESET_REQUEST); SessionCommands.connect({ ...options, reason: App.WebSocketConnectReason.PASSWORD_RESET_REQUEST });
} }
static resetPasswordChallenge(options: WebSocketConnectOptions): void { static resetPasswordChallenge(options: Omit<Enriched.PasswordResetChallengeConnectOptions, 'reason'>): void {
SessionCommands.connect(options, WebSocketConnectReason.PASSWORD_RESET_CHALLENGE); SessionCommands.connect({ ...options, reason: App.WebSocketConnectReason.PASSWORD_RESET_CHALLENGE });
} }
static resetPassword(options: WebSocketConnectOptions): void { static resetPassword(options: Omit<Enriched.PasswordResetConnectOptions, 'reason'>): void {
SessionCommands.connect(options, WebSocketConnectReason.PASSWORD_RESET); SessionCommands.connect({ ...options, reason: App.WebSocketConnectReason.PASSWORD_RESET });
} }
static disconnect(): void { static disconnect(): void {
@ -36,11 +35,11 @@ export class AuthenticationService {
} }
static isConnected(state: number): boolean { static isConnected(state: number): boolean {
return state === StatusEnum.LOGGED_IN; return state === App.StatusEnum.LOGGED_IN;
} }
static isModerator(user: ServerInfo_User): boolean { static isModerator(user: Data.ServerInfo_User): boolean {
const moderatorLevel = ServerInfo_User_UserLevelFlag.IsModerator; const moderatorLevel = Data.ServerInfo_User_UserLevelFlag.IsModerator;
// @TODO tell cockatrice not to do this so shittily // @TODO tell cockatrice not to do this so shittily
return (user.userLevel & moderatorLevel) === moderatorLevel; return (user.userLevel & moderatorLevel) === moderatorLevel;
} }

View file

@ -1,4 +1,4 @@
vi.mock('websocket', () => ({ vi.mock('@app/websocket', () => ({
ModeratorCommands: { ModeratorCommands: {
banFromServer: vi.fn(), banFromServer: vi.fn(),
getBanHistory: vi.fn(), getBanHistory: vi.fn(),
@ -10,10 +10,8 @@ vi.mock('websocket', () => ({
})); }));
import { ModeratorService } from './ModeratorService'; import { ModeratorService } from './ModeratorService';
import { ModeratorCommands } from 'websocket'; import { ModeratorCommands } from '@app/websocket';
import { LogFilters } from 'types'; import { Data } from '@app/types';
beforeEach(() => vi.clearAllMocks());
describe('ModeratorService', () => { describe('ModeratorService', () => {
describe('banFromServer', () => { describe('banFromServer', () => {
@ -55,7 +53,7 @@ describe('ModeratorService', () => {
describe('viewLogHistory', () => { describe('viewLogHistory', () => {
it('delegates to ModeratorCommands.viewLogHistory', () => { it('delegates to ModeratorCommands.viewLogHistory', () => {
const filters: LogFilters = { dateRange: 7, userName: 'alice' }; const filters: Data.ViewLogHistoryParams = { dateRange: 7, userName: 'alice' };
ModeratorService.viewLogHistory(filters); ModeratorService.viewLogHistory(filters);
expect(ModeratorCommands.viewLogHistory).toHaveBeenCalledWith(filters); expect(ModeratorCommands.viewLogHistory).toHaveBeenCalledWith(filters);
}); });

View file

@ -1,5 +1,5 @@
import { ModeratorCommands } from 'websocket'; import { ModeratorCommands } from '@app/websocket';
import { LogFilters } from 'types'; import { Data } from '@app/types';
export class ModeratorService { export class ModeratorService {
static banFromServer(minutes: number, userName?: string, address?: string, reason?: string, static banFromServer(minutes: number, userName?: string, address?: string, reason?: string,
@ -19,7 +19,7 @@ export class ModeratorService {
ModeratorCommands.getWarnList(modName, userName, userClientid); ModeratorCommands.getWarnList(modName, userName, userClientid);
} }
static viewLogHistory(filters: LogFilters): void { static viewLogHistory(filters: Data.ViewLogHistoryParams): void {
ModeratorCommands.viewLogHistory(filters); ModeratorCommands.viewLogHistory(filters);
} }

View file

@ -1,4 +1,4 @@
vi.mock('websocket', () => ({ vi.mock('@app/websocket', () => ({
SessionCommands: { SessionCommands: {
joinRoom: vi.fn(), joinRoom: vi.fn(),
}, },
@ -9,9 +9,7 @@ vi.mock('websocket', () => ({
})); }));
import { RoomsService } from './RoomsService'; import { RoomsService } from './RoomsService';
import { RoomCommands, SessionCommands } from 'websocket'; import { RoomCommands, SessionCommands } from '@app/websocket';
beforeEach(() => vi.clearAllMocks());
describe('RoomsService', () => { describe('RoomsService', () => {
describe('joinRoom', () => { describe('joinRoom', () => {

View file

@ -1,4 +1,4 @@
import { RoomCommands, SessionCommands } from 'websocket'; import { RoomCommands, SessionCommands } from '@app/websocket';
export class RoomsService { export class RoomsService {
static joinRoom(roomId: number): void { static joinRoom(roomId: number): void {

View file

@ -1,4 +1,4 @@
vi.mock('websocket', () => ({ vi.mock('@app/websocket', () => ({
SessionCommands: { SessionCommands: {
addToBuddyList: vi.fn(), addToBuddyList: vi.fn(),
removeFromBuddyList: vi.fn(), removeFromBuddyList: vi.fn(),
@ -14,9 +14,7 @@ vi.mock('websocket', () => ({
})); }));
import { SessionService } from './SessionService'; import { SessionService } from './SessionService';
import { SessionCommands } from 'websocket'; import { SessionCommands } from '@app/websocket';
beforeEach(() => vi.clearAllMocks());
describe('SessionService', () => { describe('SessionService', () => {
describe('addToBuddyList', () => { describe('addToBuddyList', () => {

View file

@ -1,4 +1,4 @@
import { SessionCommands } from 'websocket'; import { SessionCommands } from '@app/websocket';
export class SessionService { export class SessionService {
static addToBuddyList(userName: string) { static addToBuddyList(userName: string) {

View file

@ -1,7 +1,7 @@
// eslint-disable-next-line // eslint-disable-next-line
import React, { useMemo, useState } from 'react'; import React, { useMemo, useState } from 'react';
import { CardDTO } from 'services'; import { CardDTO } from '@app/services';
import './Card.css'; import './Card.css';

View file

@ -1,7 +1,7 @@
// eslint-disable-next-line // eslint-disable-next-line
import React, { useMemo, useState } from 'react'; import React, { useMemo, useState } from 'react';
import { CardDTO } from 'services'; import { CardDTO } from '@app/services';
import Card from '../Card/Card'; import Card from '../Card/Card';

View file

@ -4,9 +4,9 @@ import FormControl from '@mui/material/FormControl';
import InputLabel from '@mui/material/InputLabel'; import InputLabel from '@mui/material/InputLabel';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useLocaleSort } from 'hooks'; import { useLocaleSort } from '@app/hooks';
import { Images } from 'images/Images'; import { Images } from '@app/images';
import { countryCodes } from 'types'; import { App } from '@app/types';
import './CountryDropdown.css'; import './CountryDropdown.css';
@ -18,7 +18,7 @@ const CountryDropdown = ({ input: { onChange } }) => {
useEffect(() => onChange(value), [value]); useEffect(() => onChange(value), [value]);
const translateCountry = country => t(`Common.countries.${country}`); const translateCountry = country => t(`Common.countries.${country}`);
const sortedCountries = useLocaleSort(countryCodes, translateCountry); const sortedCountries = useLocaleSort(App.countryCodes, translateCountry);
return ( return (
<FormControl size='small' variant='outlined' className='CountryDropdown'> <FormControl size='small' variant='outlined' className='CountryDropdown'>

View file

@ -1,15 +1,15 @@
import React from 'react'; import React from 'react';
import { Navigate } from 'react-router-dom'; import { Navigate } from 'react-router-dom';
import { ServerSelectors } from 'store'; import { ServerSelectors } from '@app/store';
import { RouteEnum } from 'types'; import { App } from '@app/types';
import { useAppSelector } from 'store/store'; import { useAppSelector } from '@app/store';
import { AuthenticationService } from 'api'; import { AuthenticationService } from '@app/api';
const AuthGuard = () => { const AuthGuard = () => {
const state = useAppSelector(s => ServerSelectors.getState(s)); const state = useAppSelector(s => ServerSelectors.getState(s));
return !AuthenticationService.isConnected(state) return !AuthenticationService.isConnected(state)
? <Navigate to={RouteEnum.LOGIN} /> ? <Navigate to={App.RouteEnum.LOGIN} />
: <div></div>; : <div></div>;
}; };

View file

@ -1,15 +1,15 @@
import React from 'react'; import React from 'react';
import { Navigate } from 'react-router-dom'; import { Navigate } from 'react-router-dom';
import { ServerSelectors } from 'store'; import { ServerSelectors } from '@app/store';
import { AuthenticationService } from 'api'; import { AuthenticationService } from '@app/api';
import { RouteEnum } from 'types'; import { App } from '@app/types';
import { useAppSelector } from 'store/store'; import { useAppSelector } from '@app/store';
const ModGuard = () => { const ModGuard = () => {
const user = useAppSelector(state => ServerSelectors.getUser(state)); const user = useAppSelector(state => ServerSelectors.getUser(state));
return !AuthenticationService.isModerator(user) return !AuthenticationService.isModerator(user)
? <Navigate to={RouteEnum.SERVER} /> ? <Navigate to={App.RouteEnum.SERVER} />
: <></>; : <></>;
}; };

View file

@ -2,7 +2,7 @@ import React from 'react';
import { Field } from 'react-final-form' import { Field } from 'react-final-form'
import Button from '@mui/material/Button'; import Button from '@mui/material/Button';
import { InputField } from 'components'; import { InputField } from '..';
import './InputAction.css'; import './InputAction.css';

View file

@ -13,13 +13,13 @@ 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 { AuthenticationService } from 'api'; import { AuthenticationService } from '@app/api';
import { KnownHostDialog } from 'dialogs'; import { KnownHostDialog } from '@app/dialogs';
import { useReduxEffect } from 'hooks'; import { useReduxEffect } from '@app/hooks';
import { HostDTO } from 'services'; import { HostDTO } from '@app/services';
import { ServerTypes } from 'store'; import { ServerTypes } from '@app/store';
import { DefaultHosts, Host, getHostPort } from 'types'; import { App } from '@app/types';
import Toast from 'components/Toast/Toast'; import Toast from '../Toast/Toast';
import './KnownHosts.css'; import './KnownHosts.css';
@ -86,7 +86,7 @@ const KnownHosts = (props) => {
if (!hosts?.length) { if (!hosts?.length) {
// @TODO: find a better pattern to seeding default data in indexedDB // @TODO: find a better pattern to seeding default data in indexedDB
await HostDTO.bulkAdd(DefaultHosts); await HostDTO.bulkAdd(App.DefaultHosts);
loadKnownHosts(); loadKnownHosts();
} else { } else {
const selectedHost = hosts.find(({ lastSelected }) => lastSelected) || hosts[0]; const selectedHost = hosts.find(({ lastSelected }) => lastSelected) || hosts[0];
@ -159,7 +159,7 @@ const KnownHosts = (props) => {
})); }));
setShowEditToast(true) setShowEditToast(true)
} else { } else {
const newHost: Host = { name, host, port, editable: true }; const newHost: App.Host = { name, host, port, editable: true };
newHost.id = await HostDTO.add(newHost) as number; newHost.id = await HostDTO.add(newHost) as number;
setHostsState(s => ({ setHostsState(s => ({
@ -196,7 +196,7 @@ const KnownHosts = (props) => {
const testConnection = () => { const testConnection = () => {
setTestingConnection(TestConnection.TESTING); setTestingConnection(TestConnection.TESTING);
const options = { ...getHostPort(hostsState.selectedHost) }; const options = { ...App.getHostPort(hostsState.selectedHost) };
AuthenticationService.testConnection(options); AuthenticationService.testConnection(options);
} }
@ -236,34 +236,38 @@ const KnownHosts = (props) => {
</Button> </Button>
{ {
hostsState.hosts.map((host, index) => ( hostsState.hosts.map((host, index) => {
<MenuItem value={host} key={index}> const hostPort = App.getHostPort(hostsState.hosts[index]);
<div className='KnownHosts-item'>
<div className='KnownHosts-item__wrapper'> return (
<div className={'KnownHosts-item__status ' + testingConnection}> <MenuItem value={host} key={index}>
{ <div className='KnownHosts-item'>
testingConnection === TestConnection.FAILED <div className='KnownHosts-item__wrapper'>
? <PortableWifiOffIcon fontSize="small" /> <div className={'KnownHosts-item__status ' + testingConnection}>
: <WifiTetheringIcon fontSize="small" /> {
} testingConnection === TestConnection.FAILED
? <PortableWifiOffIcon fontSize="small" />
: <WifiTetheringIcon fontSize="small" />
}
</div>
<div className='KnownHosts-item__label'>
<Check />
<span>{host.name} ({ hostPort.host }:{hostPort.port})</span>
</div>
</div> </div>
<div className='KnownHosts-item__label'> { host.editable && (
<Check /> <IconButton className='KnownHosts-item__edit' size='small' color='primary' onClick={() => {
<span>{host.name} ({ getHostPort(hostsState.hosts[index]).host }:{getHostPort(hostsState.hosts[index]).port})</span> openEditKnownHostDialog(hostsState.hosts[index]);
</div> }}>
<EditRoundedIcon fontSize='small' />
</IconButton>
) }
</div> </div>
</MenuItem>
{ host.editable && ( );
<IconButton className='KnownHosts-item__edit' size='small' color='primary' onClick={() => { })
openEditKnownHostDialog(hostsState.hosts[index]);
}}>
<EditRoundedIcon fontSize='small' />
</IconButton>
) }
</div>
</MenuItem>
))
} }
</Select> </Select>
</FormControl> </FormControl>

View file

@ -4,8 +4,8 @@ import { useTranslation } from 'react-i18next';
import { Select, MenuItem } from '@mui/material'; import { Select, MenuItem } from '@mui/material';
import FormControl from '@mui/material/FormControl'; import FormControl from '@mui/material/FormControl';
import { Images } from 'images/Images'; import { Images } from '@app/images';
import { Language, LanguageCountry, LanguageNative } from 'types'; import { App } from '@app/types';
import './LanguageDropdown.css'; import './LanguageDropdown.css';
@ -26,19 +26,19 @@ const LanguageDropdown = () => {
margin='dense' margin='dense'
value={language} value={language}
fullWidth={true} fullWidth={true}
onChange={e => setLanguage(e.target.value as Language)} onChange={e => setLanguage(e.target.value as App.Language)}
> >
{ {
Object.keys(Language).map((lang) => { Object.keys(App.Language).map((lang) => {
const country = LanguageCountry[lang]; const country = App.LanguageCountry[lang];
return ( return (
<MenuItem value={lang} key={lang}> <MenuItem value={lang} key={lang}>
<div className="LanguageDropdown-item"> <div className="LanguageDropdown-item">
<img className="LanguageDropdown-item__image" src={Images.Countries[country]} /> <img className="LanguageDropdown-item__image" src={Images.Countries[country]} />
<span className="LanguageDropdown-item__label"> <span className="LanguageDropdown-item__label">
{LanguageNative[lang]} { {App.LanguageNative[lang]} {
LanguageNative[lang] !== t(`Common.languages.${lang}`) && ( App.LanguageNative[lang] !== t(`Common.languages.${lang}`) && (
<>({ t(`Common.languages.${lang}`) })</> <>({ t(`Common.languages.${lang}`) })</>
) )
} }

View file

@ -3,7 +3,7 @@ import React, { useMemo, useState } from 'react';
import { styled } from '@mui/material/styles'; import { styled } from '@mui/material/styles';
import Popover from '@mui/material/Popover'; import Popover from '@mui/material/Popover';
import { CardDTO, TokenDTO } from 'services'; import { CardDTO, TokenDTO } from '@app/services';
import CardDetails from '../CardDetails/CardDetails'; import CardDetails from '../CardDetails/CardDetails';
import TokenDetails from '../TokenDetails/TokenDetails'; import TokenDetails from '../TokenDetails/TokenDetails';

View file

@ -3,14 +3,7 @@ import React, { useEffect, useMemo, useState } from 'react';
import { NavLink, generatePath } from 'react-router-dom'; import { NavLink, generatePath } from 'react-router-dom';
import { import { App } from '@app/types';
RouteEnum,
URL_REGEX,
MESSAGE_SENDER_REGEX,
MENTION_REGEX,
CARD_CALLOUT_REGEX,
CALLOUT_BOUNDARY_REGEX,
} from 'types';
import CardCallout from './CardCallout'; import CardCallout from './CardCallout';
import './Message.css'; import './Message.css';
@ -28,7 +21,7 @@ const ParsedMessage = ({ message }) => {
const [name, setName] = useState(null); const [name, setName] = useState(null);
useMemo(() => { useMemo(() => {
const name = message.match(MESSAGE_SENDER_REGEX); const name = message.match(App.MESSAGE_SENDER_REGEX);
if (name) { if (name) {
setName(name[1]); setName(name[1]);
@ -46,29 +39,29 @@ const ParsedMessage = ({ message }) => {
}; };
const PlayerLink = ({ name, label = name }) => ( const PlayerLink = ({ name, label = name }) => (
<NavLink className="link" to={generatePath(RouteEnum.PLAYER, { name })}> <NavLink className="link" to={generatePath(App.RouteEnum.PLAYER, { name })}>
{label} {label}
</NavLink> </NavLink>
); );
function parseMessage(message) { function parseMessage(message) {
return message.replace(MESSAGE_SENDER_REGEX, '') return message.replace(App.MESSAGE_SENDER_REGEX, '')
.split(CARD_CALLOUT_REGEX) .split(App.CARD_CALLOUT_REGEX)
.filter(chunk => !!chunk) .filter(chunk => !!chunk)
.map(parseChunks); .map(parseChunks);
} }
function parseChunks(chunk, index) { function parseChunks(chunk, index) {
if (chunk.match(CARD_CALLOUT_REGEX)) { if (chunk.match(App.CARD_CALLOUT_REGEX)) {
const name = chunk.replace(CALLOUT_BOUNDARY_REGEX, '').trim(); const name = chunk.replace(App.CALLOUT_BOUNDARY_REGEX, '').trim();
return (<CardCallout name={name} key={index}></CardCallout>); return (<CardCallout name={name} key={index}></CardCallout>);
} }
if (chunk.match(URL_REGEX)) { if (chunk.match(App.URL_REGEX)) {
return parseUrlChunk(chunk); return parseUrlChunk(chunk);
} }
if (chunk.match(MENTION_REGEX)) { if (chunk.match(App.MENTION_REGEX)) {
return parseMentionChunk(chunk); return parseMentionChunk(chunk);
} }
@ -76,10 +69,10 @@ function parseChunks(chunk, index) {
} }
function parseUrlChunk(chunk) { function parseUrlChunk(chunk) {
return chunk.split(URL_REGEX) return chunk.split(App.URL_REGEX)
.filter(urlChunk => !!urlChunk) .filter(urlChunk => !!urlChunk)
.map((urlChunk, index) => { .map((urlChunk, index) => {
if (urlChunk.match(URL_REGEX)) { if (urlChunk.match(App.URL_REGEX)) {
return (<a className='link' href={urlChunk} key={index} target='_blank' rel='noopener noreferrer'>{urlChunk}</a>); return (<a className='link' href={urlChunk} key={index} target='_blank' rel='noopener noreferrer'>{urlChunk}</a>);
} }
@ -88,10 +81,10 @@ function parseUrlChunk(chunk) {
} }
function parseMentionChunk(chunk) { function parseMentionChunk(chunk) {
return chunk.split(MENTION_REGEX) return chunk.split(App.MENTION_REGEX)
.filter(mentionChunk => !!mentionChunk) .filter(mentionChunk => !!mentionChunk)
.map((mentionChunk, index) => { .map((mentionChunk, index) => {
const mention = mentionChunk.match(MENTION_REGEX); const mention = mentionChunk.match(App.MENTION_REGEX);
if (mention) { if (mention) {
const name = mention[0].substr(1); const name = mention[0].substr(1);

View file

@ -1,7 +1,7 @@
// eslint-disable-next-line // eslint-disable-next-line
import React, { useMemo, useState } from 'react'; import React, { useMemo, useState } from 'react';
import { TokenDTO } from 'services'; import { TokenDTO } from '@app/services';
import './Token.css'; import './Token.css';

View file

@ -1,7 +1,7 @@
// eslint-disable-next-line // eslint-disable-next-line
import React, { useMemo, useState } from 'react'; import React, { useMemo, useState } from 'react';
import { TokenDTO } from 'services'; import { TokenDTO } from '@app/services';
import Token from '../Token/Token'; import Token from '../Token/Token';

View file

@ -5,12 +5,11 @@ import { NavLink, generatePath } from 'react-router-dom';
import Menu from '@mui/material/Menu'; import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem'; import MenuItem from '@mui/material/MenuItem';
import { Images } from 'images/Images'; import { Images } from '@app/images';
import { SessionService } from 'api'; import { SessionService } from '@app/api';
import { ServerSelectors } from 'store'; import { ServerSelectors } from '@app/store';
import { RouteEnum } from 'types'; import { App, Data } from '@app/types';
import type { ServerInfo_User } from 'generated/proto/serverinfo_user_pb'; import { useAppSelector } from '@app/store';
import { useAppSelector } from 'store/store';
import './UserDisplay.css'; import './UserDisplay.css';
@ -51,7 +50,7 @@ const UserDisplay = ({ user }: UserDisplayProps) => {
return ( return (
<div className="user-display"> <div className="user-display">
<NavLink to={generatePath(RouteEnum.PLAYER, { name })} className="plain-link"> <NavLink to={generatePath(App.RouteEnum.PLAYER, { name })} className="plain-link">
<div className="user-display__details" onContextMenu={handleClick}> <div className="user-display__details" onContextMenu={handleClick}>
<img className="user-display__country" src={Images.Countries[country]} alt={country}></img> <img className="user-display__country" src={Images.Countries[country]} alt={country}></img>
<div className="user-display__name single-line-ellipsis">{name}</div> <div className="user-display__name single-line-ellipsis">{name}</div>
@ -68,7 +67,7 @@ const UserDisplay = ({ user }: UserDisplayProps) => {
: undefined : undefined
} }
> >
<NavLink to={generatePath(RouteEnum.PLAYER, { name })} className="user-display__link plain-link"> <NavLink to={generatePath(App.RouteEnum.PLAYER, { name })} className="user-display__link plain-link">
<MenuItem dense>Chat</MenuItem> <MenuItem dense>Chat</MenuItem>
</NavLink> </NavLink>
{ {
@ -88,7 +87,7 @@ const UserDisplay = ({ user }: UserDisplayProps) => {
}; };
interface UserDisplayProps { interface UserDisplayProps {
user: ServerInfo_User; user: Data.ServerInfo_User;
} }
export default UserDisplay; export default UserDisplay;

View file

@ -17,3 +17,6 @@ export { default as ScrollToBottomOnChanges } from './ScrollToBottomOnChanges/Sc
// Guards // Guards
export { default as AuthGuard } from './Guard/AuthGuard'; export { default as AuthGuard } from './Guard/AuthGuard';
export { default as ModGuard } from './Guard/ModGuard'; export { default as ModGuard } from './Guard/ModGuard';
// Toast
export { default as Toast, useToast, ToastProvider } from './Toast';

View file

@ -6,11 +6,11 @@ import Button from '@mui/material/Button';
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 { UserDisplay, VirtualList, AuthGuard, LanguageDropdown } from 'components'; import { UserDisplay, VirtualList, AuthGuard, LanguageDropdown } from '@app/components';
import { AuthenticationService, SessionService } from 'api'; import { AuthenticationService, SessionService } from '@app/api';
import { ServerSelectors } from 'store'; import { ServerSelectors } from '@app/store';
import Layout from 'containers/Layout/Layout'; import Layout from '../Layout/Layout';
import { useAppSelector } from 'store/store'; import { useAppSelector } from '@app/store';
import AddToBuddies from './AddToBuddies'; import AddToBuddies from './AddToBuddies';
import AddToIgnore from './AddToIgnore'; import AddToIgnore from './AddToIgnore';

View file

@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
import { Form } from 'react-final-form' import { Form } from 'react-final-form'
import { InputAction } from 'components'; import { InputAction } from '@app/components';
const AddToBuddies = ({ onSubmit }) => ( const AddToBuddies = ({ onSubmit }) => (
<Form onSubmit={values => onSubmit(values)}> <Form onSubmit={values => onSubmit(values)}>

View file

@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
import { Form } from 'react-final-form' import { Form } from 'react-final-form'
import { InputAction } from 'components'; import { InputAction } from '@app/components';
const AddToIgnore = ({ onSubmit }) => ( const AddToIgnore = ({ onSubmit }) => (
<Form onSubmit={values => onSubmit(values)}> <Form onSubmit={values => onSubmit(values)}>

View file

@ -2,13 +2,13 @@ import { Component, Suspense } from 'react';
import { Provider } from 'react-redux'; import { Provider } from 'react-redux';
import { MemoryRouter as Router } from 'react-router-dom'; import { MemoryRouter as Router } from 'react-router-dom';
import CssBaseline from '@mui/material/CssBaseline'; import CssBaseline from '@mui/material/CssBaseline';
import { store } from 'store'; import { store } from '@app/store';
import Routes from './AppShellRoutes'; import Routes from './AppShellRoutes';
import FeatureDetection from './FeatureDetection'; import FeatureDetection from './FeatureDetection';
import './AppShell.css'; import './AppShell.css';
import { ToastProvider } from 'components/Toast' import { ToastProvider } from '@app/components'
class AppShell extends Component { class AppShell extends Component {
componentDidMount() { componentDidMount() {

View file

@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
import { Route, Routes } from 'react-router-dom'; import { Route, Routes } from 'react-router-dom';
import { RouteEnum } from 'types'; import { App } from '@app/types';
import { import {
Account, Account,
Decks, Decks,
@ -13,22 +13,22 @@ import {
Logs, Logs,
Initialize, Initialize,
Unsupported Unsupported
} from 'containers'; } from '..';
const AppShellRoutes = () => ( const AppShellRoutes = () => (
<div className="AppShell-routes overflow-scroll"> <div className="AppShell-routes overflow-scroll">
<Routes> <Routes>
<Route path='*' element={<Initialize />} /> <Route path='*' element={<Initialize />} />
<Route path={RouteEnum.ACCOUNT} element={<Account />} /> <Route path={App.RouteEnum.ACCOUNT} element={<Account />} />
<Route path={RouteEnum.DECKS} element={<Decks />} /> <Route path={App.RouteEnum.DECKS} element={<Decks />} />
<Route path={RouteEnum.GAME} element={<Game />} /> <Route path={App.RouteEnum.GAME} element={<Game />} />
<Route path={RouteEnum.LOGS} element={<Logs />} /> <Route path={App.RouteEnum.LOGS} element={<Logs />} />
<Route path={RouteEnum.PLAYER} element={<Player />} /> <Route path={App.RouteEnum.PLAYER} element={<Player />} />
{<Route path={RouteEnum.ROOM} element={<Room />} />} {<Route path={App.RouteEnum.ROOM} element={<Room />} />}
<Route path={RouteEnum.SERVER} element={<Server />} /> <Route path={App.RouteEnum.SERVER} element={<Server />} />
<Route path={RouteEnum.LOGIN} element={<Login />} /> <Route path={App.RouteEnum.LOGIN} element={<Login />} />
<Route path={RouteEnum.UNSUPPORTED} element={<Unsupported />} /> <Route path={App.RouteEnum.UNSUPPORTED} element={<Unsupported />} />
</Routes> </Routes>
</div> </div>
); );

View file

@ -1,7 +1,7 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Navigate } from 'react-router-dom'; import { Navigate } from 'react-router-dom';
import { dexieService } from 'services'; import { dexieService } from '@app/services';
import { RouteEnum } from 'types'; import { App } from '@app/types';
const FeatureDetection = () => { const FeatureDetection = () => {
const [unsupported, setUnsupported] = useState(false); const [unsupported, setUnsupported] = useState(false);
@ -15,7 +15,7 @@ const FeatureDetection = () => {
}, []); }, []);
return unsupported return unsupported
? <Navigate to={RouteEnum.UNSUPPORTED} /> ? <Navigate to={App.RouteEnum.UNSUPPORTED} />
: <></>; : <></>;
function detectIndexedDB() { function detectIndexedDB() {

View file

@ -1,8 +1,8 @@
// eslint-disable-next-line // eslint-disable-next-line
import React, { Component } from "react"; import React, { Component } from "react";
import { AuthGuard } from 'components/index'; import { AuthGuard } from '@app/components';
import Layout from 'containers/Layout/Layout'; import Layout from '../Layout/Layout';
import './Decks.css'; import './Decks.css';

View file

@ -1,8 +1,8 @@
// eslint-disable-next-line // eslint-disable-next-line
import React, { Component } from "react"; import React, { Component } from "react";
import { AuthGuard } from 'components'; import { AuthGuard } from '@app/components';
import Layout from 'containers/Layout/Layout'; import Layout from '../Layout/Layout';
import './Game.css'; import './Game.css';

View file

@ -3,11 +3,11 @@ import { useTranslation, Trans } from 'react-i18next';
import { Navigate } from 'react-router-dom'; import { Navigate } from 'react-router-dom';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import { Images } from 'images'; import { Images } from '@app/images';
import { ServerSelectors } from 'store'; import { ServerSelectors } from '@app/store';
import { RouteEnum } from 'types'; import { App } from '@app/types';
import Layout from 'containers/Layout/Layout'; import Layout from '../Layout/Layout';
import { useAppSelector } from 'store/store'; import { useAppSelector } from '@app/store';
import './Initialize.css'; import './Initialize.css';
@ -34,7 +34,7 @@ const Initialize = () => {
const { t } = useTranslation(); const { t } = useTranslation();
return initialized return initialized
? <Navigate to={RouteEnum.LOGIN} /> ? <Navigate to={App.RouteEnum.LOGIN} />
: ( : (
<Layout> <Layout>
<Root className={'Initialize ' + classes.root}> <Root className={'Initialize ' + classes.root}>

View file

@ -8,12 +8,12 @@ import CloseIcon from '@mui/icons-material/Close';
import MailOutlineRoundedIcon from '@mui/icons-material/MailOutline'; import MailOutlineRoundedIcon from '@mui/icons-material/MailOutline';
import MenuRoundedIcon from '@mui/icons-material/MenuRounded'; import MenuRoundedIcon from '@mui/icons-material/MenuRounded';
import { AuthenticationService, RoomsService } from 'api'; import { AuthenticationService, RoomsService } from '@app/api';
import { CardImportDialog } from 'dialogs'; import { CardImportDialog } from '@app/dialogs';
import { Images } from 'images'; import { Images } from '@app/images';
import { RoomsSelectors, ServerSelectors } from 'store'; import { RoomsSelectors, ServerSelectors } from '@app/store';
import { RouteEnum } from 'types'; import { App } from '@app/types';
import { useAppSelector } from 'store/store'; import { useAppSelector } from '@app/store';
import './LeftNav.css'; import './LeftNav.css';
@ -82,7 +82,7 @@ const LeftNav = () => {
<div className="LeftNav__container"> <div className="LeftNav__container">
<div> <div>
<div className="LeftNav__logo"> <div className="LeftNav__logo">
<NavLink to={RouteEnum.SERVER}> <NavLink to={App.RouteEnum.SERVER}>
<img src={Images.Logo} alt="logo" /> <img src={Images.Logo} alt="logo" />
</NavLink> </NavLink>
{ AuthenticationService.isConnected(serverState) && ( { AuthenticationService.isConnected(serverState) && (
@ -98,8 +98,8 @@ const LeftNav = () => {
className="LeftNav-nav__link-btn" className="LeftNav-nav__link-btn"
to={ to={
joinedRooms.length joinedRooms.length
? generatePath(RouteEnum.ROOM, { roomId: joinedRooms[0].roomId.toString() }) ? generatePath(App.RouteEnum.ROOM, { roomId: joinedRooms[0].roomId.toString() })
: RouteEnum.SERVER : App.RouteEnum.SERVER
} }
> >
Rooms Rooms
@ -108,7 +108,9 @@ const LeftNav = () => {
<div className="LeftNav-nav__link-menu"> <div className="LeftNav-nav__link-menu">
{joinedRooms.map(({ name, roomId }) => ( {joinedRooms.map(({ name, roomId }) => (
<div className="LeftNav-nav__link-menu__item" key={roomId}> <div className="LeftNav-nav__link-menu__item" key={roomId}>
<NavLink className="LeftNav-nav__link-menu__btn" to={ generatePath(RouteEnum.ROOM, { roomId: roomId.toString() }) }> <NavLink className="LeftNav-nav__link-menu__btn"
to={ generatePath(App.RouteEnum.ROOM, { roomId: roomId.toString() }) }
>
{name} {name}
<IconButton size="small" edge="end" onClick={event => leaveRoom(event, roomId)}> <IconButton size="small" edge="end" onClick={event => leaveRoom(event, roomId)}>
@ -120,13 +122,13 @@ const LeftNav = () => {
</div> </div>
</div> </div>
<div className="LeftNav-nav__link"> <div className="LeftNav-nav__link">
<NavLink className="LeftNav-nav__link-btn" to={ RouteEnum.GAME }> <NavLink className="LeftNav-nav__link-btn" to={ App.RouteEnum.GAME }>
Games Games
<ArrowDropDownIcon className="LeftNav-nav__link-btn__icon" fontSize="small" /> <ArrowDropDownIcon className="LeftNav-nav__link-btn__icon" fontSize="small" />
</NavLink> </NavLink>
</div> </div>
<div className="LeftNav-nav__link"> <div className="LeftNav-nav__link">
<NavLink className="LeftNav-nav__link-btn" to={ RouteEnum.DECKS }> <NavLink className="LeftNav-nav__link-btn" to={ App.RouteEnum.DECKS }>
Decks Decks
<ArrowDropDownIcon className="LeftNav-nav__link-btn__icon" fontSize="small" /> <ArrowDropDownIcon className="LeftNav-nav__link-btn__icon" fontSize="small" />
</NavLink> </NavLink>

View file

@ -6,20 +6,20 @@ 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 { AuthenticationService } from 'api'; import { AuthenticationService } from '@app/api';
import { RegistrationDialog, RequestPasswordResetDialog, ResetPasswordDialog, AccountActivationDialog } from 'dialogs'; import { RegistrationDialog, RequestPasswordResetDialog, ResetPasswordDialog, AccountActivationDialog } from '@app/dialogs';
import { LanguageDropdown } from 'components'; import { LanguageDropdown } from '@app/components';
import { LoginForm } from 'forms'; import { LoginForm } from '@app/forms';
import { useReduxEffect, useFireOnce } from 'hooks'; import { useReduxEffect, useFireOnce } from '@app/hooks';
import { Images } from 'images'; import { Images } from '@app/images';
import { HostDTO, serverProps } from 'services'; import { HostDTO, serverProps } from '@app/services';
import { RouteEnum, WebSocketConnectOptions, getHostPort } from 'types'; import { App, Enriched } from '@app/types';
import { ServerSelectors, ServerTypes } from 'store'; import { ServerSelectors, ServerTypes } from '@app/store';
import Layout from 'containers/Layout/Layout'; import Layout from '../Layout/Layout';
import { useAppSelector } from 'store/store'; import { useAppSelector } from '@app/store';
import './Login.css'; import './Login.css';
import { useToast } from 'components/Toast'; import { useToast } from '@app/components';
const PREFIX = 'Login'; const PREFIX = 'Login';
@ -71,7 +71,7 @@ const Login = () => {
const isConnected = AuthenticationService.isConnected(state); const isConnected = AuthenticationService.isConnected(state);
const [pendingActivationOptions, setPendingActivationOptions] = useState<WebSocketConnectOptions | null>(null); const [pendingActivationOptions, setPendingActivationOptions] = useState<Enriched.PendingActivationContext | null>(null);
const [rememberLogin, setRememberLogin] = useState(null); const [rememberLogin, setRememberLogin] = useState(null);
const [dialogState, setDialogState] = useState({ const [dialogState, setDialogState] = useState({
@ -126,17 +126,17 @@ const Login = () => {
setRememberLogin(loginForm); setRememberLogin(loginForm);
const { userName, password, selectedHost, remember } = loginForm; const { userName, password, selectedHost, remember } = loginForm;
const options: WebSocketConnectOptions = { const options: Omit<Enriched.LoginConnectOptions, 'reason'> = {
...getHostPort(selectedHost), ...App.getHostPort(selectedHost),
userName, userName,
password password,
}; };
if (remember && !password) { if (remember && !password) {
options.hashedPassword = selectedHost.hashedPassword; options.hashedPassword = selectedHost.hashedPassword;
} }
AuthenticationService.login(options as WebSocketConnectOptions); AuthenticationService.login(options);
}, []); }, []);
const [submitButtonDisabled, resetSubmitButton, handleLogin] = useFireOnce(onSubmitLogin); const [submitButtonDisabled, resetSubmitButton, handleLogin] = useFireOnce(onSubmitLogin);
@ -156,7 +156,7 @@ const Login = () => {
const { userName, password, email, country, realName, selectedHost } = registerForm; const { userName, password, email, country, realName, selectedHost } = registerForm;
AuthenticationService.register({ AuthenticationService.register({
...getHostPort(selectedHost), ...App.getHostPort(selectedHost),
userName, userName,
password, password,
email, email,
@ -166,15 +166,20 @@ const Login = () => {
}; };
const handleAccountActivationDialogSubmit = ({ token }) => { const handleAccountActivationDialogSubmit = ({ token }) => {
if (!pendingActivationOptions) {
return;
}
AuthenticationService.activateAccount({ AuthenticationService.activateAccount({
...pendingActivationOptions, host: pendingActivationOptions.host,
port: pendingActivationOptions.port,
userName: pendingActivationOptions.userName,
token, token,
}); });
}; };
const handleRequestPasswordResetDialogSubmit = (form) => { const handleRequestPasswordResetDialogSubmit = (form) => {
const { userName, email, selectedHost } = form; const { userName, email, selectedHost } = form;
const { host, port } = getHostPort(selectedHost); const { host, port } = App.getHostPort(selectedHost);
if (email) { if (email) {
AuthenticationService.resetPasswordChallenge({ userName, email, host, port }); AuthenticationService.resetPasswordChallenge({ userName, email, host, port });
@ -185,7 +190,7 @@ const Login = () => {
}; };
const handleResetPasswordDialogSubmit = ({ userName, token, newPassword, selectedHost }) => { const handleResetPasswordDialogSubmit = ({ userName, token, newPassword, selectedHost }) => {
const { host, port } = getHostPort(selectedHost); const { host, port } = App.getHostPort(selectedHost);
AuthenticationService.resetPassword({ userName, token, newPassword, host, port }); AuthenticationService.resetPassword({ userName, token, newPassword, host, port });
}; };
@ -234,7 +239,7 @@ const Login = () => {
return ( return (
<Layout showNav={false} noHeightLimit={true}> <Layout showNav={false} noHeightLimit={true}>
<Root className={'login overflow-scroll ' + classes.root}> <Root className={'login overflow-scroll ' + classes.root}>
{ isConnected && <Navigate to={RouteEnum.SERVER} />} { isConnected && <Navigate to={App.RouteEnum.SERVER} />}
<div className="login__wrapper"> <div className="login__wrapper">
<Paper className="login-content"> <Paper className="login-content">

View file

@ -2,12 +2,12 @@
import React, { useEffect } from "react"; import React, { useEffect } from "react";
import * as _ from 'lodash'; import * as _ from 'lodash';
import { ModeratorService } from 'api'; import { ModeratorService } from '@app/api';
import { AuthGuard, ModGuard } from 'components'; import { AuthGuard, ModGuard } from '@app/components';
import { SearchForm } from 'forms'; import { SearchForm } from '@app/forms';
import { ServerDispatch, ServerSelectors } from 'store'; import { ServerDispatch, ServerSelectors } from '@app/store';
import { LogFilters } from 'types'; import { Data } from '@app/types';
import { useAppSelector } from 'store/store'; import { useAppSelector } from '@app/store';
import LogResults from './LogResults'; import LogResults from './LogResults';
import './Logs.css'; import './Logs.css';
@ -43,7 +43,7 @@ const Logs = () => {
}, []); }, []);
}; };
const onSubmit = (fields: LogFilters) => { const onSubmit = (fields: Data.ViewLogHistoryParams) => {
const trimmedFields: any = trimFields(fields); const trimmedFields: any = trimFields(fields);
const { userName, ipAddress, gameName, gameId, message, logLocation } = trimmedFields; const { userName, ipAddress, gameName, gameId, message, logLocation } = trimmedFields;

View file

@ -1,8 +1,8 @@
// eslint-disable-next-line // eslint-disable-next-line
import React, { Component } from "react"; import React, { Component } from "react";
import Layout from 'containers/Layout/Layout'; import Layout from '../Layout/Layout';
import { AuthGuard } from 'components'; import { AuthGuard } from '@app/components';
class Player extends Component { class Player extends Component {
render() { render() {

View file

@ -12,9 +12,9 @@ import Tooltip from '@mui/material/Tooltip';
// import { RoomsService } from "AppShell/common/services"; // import { RoomsService } from "AppShell/common/services";
import { SortUtil, RoomsDispatch, RoomsSelectors } from 'store'; import { SortUtil, RoomsDispatch, RoomsSelectors } from '@app/store';
import { UserDisplay } from 'components'; import { UserDisplay } from '@app/components';
import { useAppSelector } from 'store/store'; import { useAppSelector } from '@app/store';
import './Games.css'; import './Games.css';

View file

@ -1,7 +1,7 @@
// eslint-disable-next-line // eslint-disable-next-line
import React from "react"; import React from "react";
import { Message } from 'components'; import { Message } from '@app/components';
import './Messages.css'; import './Messages.css';

View file

@ -12,9 +12,9 @@ import Tooltip from '@mui/material/Tooltip';
// import { RoomsService } from "AppShell/common/services"; // import { RoomsService } from "AppShell/common/services";
import { SortUtil, RoomsDispatch, RoomsSelectors } from 'store'; import { SortUtil, RoomsDispatch, RoomsSelectors } from '@app/store';
import { UserDisplay } from 'components'; import { UserDisplay } from '@app/components';
import { useAppSelector } from 'store/store'; import { useAppSelector } from '@app/store';
import './OpenGames.css'; import './OpenGames.css';

View file

@ -4,12 +4,12 @@ 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 { RoomsService } from 'api'; import { RoomsService } from '@app/api';
import { ScrollToBottomOnChanges, ThreePaneLayout, UserDisplay, VirtualList, AuthGuard } from 'components'; import { ScrollToBottomOnChanges, ThreePaneLayout, UserDisplay, VirtualList, AuthGuard } from '@app/components';
import { RoomsSelectors } from 'store'; import { RoomsSelectors } from '@app/store';
import { useAppSelector } from 'store/store'; import { useAppSelector } from '@app/store';
import { RouteEnum } from 'types'; import { App } from '@app/types';
import Layout from 'containers/Layout/Layout'; import Layout from '../Layout/Layout';
import OpenGames from './OpenGames'; import OpenGames from './OpenGames';
import Messages from './Messages'; import Messages from './Messages';
@ -32,7 +32,7 @@ const Room = () => {
useEffect(() => { useEffect(() => {
if (!joined.find(({ roomId: id }) => id === roomId)) { if (!joined.find(({ roomId: id }) => id === roomId)) {
navigate(generatePath(RouteEnum.SERVER)); navigate(generatePath(App.RouteEnum.SERVER));
} }
}, [joined]); }, [joined]);

View file

@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
import { Form } from 'react-final-form' import { Form } from 'react-final-form'
import { InputAction } from 'components'; import { InputAction } from '@app/components';
const SayMessage = ({ onSubmit }) => ( const SayMessage = ({ onSubmit }) => (
<Form onSubmit={onSubmit}> <Form onSubmit={onSubmit}>

View file

@ -11,8 +11,8 @@ import TableHead from '@mui/material/TableHead';
import TableRow from '@mui/material/TableRow'; import TableRow from '@mui/material/TableRow';
import { RoomsService } from 'api'; import { RoomsService } from '@app/api';
import { RouteEnum } from 'types'; import { App } from '@app/types';
import './Rooms.css'; import './Rooms.css';
@ -21,7 +21,7 @@ const Rooms = ({ rooms, joinedRooms }) => {
function onClick(roomId) { function onClick(roomId) {
if (_.find(joinedRooms, room => room.roomId === roomId)) { if (_.find(joinedRooms, room => room.roomId === roomId)) {
navigate(generatePath(RouteEnum.ROOM, { roomId })); navigate(generatePath(App.RouteEnum.ROOM, { roomId }));
} else { } else {
RoomsService.joinRoom(roomId); RoomsService.joinRoom(roomId);
} }

View file

@ -5,13 +5,13 @@ import { generatePath, useNavigate } 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 { AuthGuard, ThreePaneLayout, UserDisplay, VirtualList } from 'components'; import { AuthGuard, ThreePaneLayout, UserDisplay, VirtualList } from '@app/components';
import { useReduxEffect } from 'hooks'; import { useReduxEffect } from '@app/hooks';
import { RoomsSelectors, RoomsTypes, ServerSelectors } from 'store'; import { RoomsSelectors, RoomsTypes, ServerSelectors } from '@app/store';
import { RouteEnum } from 'types'; import { App } from '@app/types';
import { useAppSelector } from 'store/store'; import { useAppSelector } from '@app/store';
import Rooms from './Rooms'; import Rooms from './Rooms';
import Layout from 'containers/Layout/Layout'; import Layout from '../Layout/Layout';
import './Server.css'; import './Server.css';
@ -24,7 +24,7 @@ const Server = () => {
useReduxEffect((action: any) => { useReduxEffect((action: any) => {
const roomId = action.roomInfo.roomId.toString(); const roomId = action.roomInfo.roomId.toString();
navigate(generatePath(RouteEnum.ROOM, { roomId })); navigate(generatePath(App.RouteEnum.ROOM, { roomId }));
}, RoomsTypes.JOIN_ROOM, []); }, RoomsTypes.JOIN_ROOM, []);
return ( return (

View file

@ -1,7 +1,7 @@
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
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 Layout from 'containers/Layout/Layout'; import Layout from '../Layout/Layout';
import './Unsupported.css'; import './Unsupported.css';

View file

@ -7,7 +7,7 @@ import CloseIcon from '@mui/icons-material/Close';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { AccountActivationForm } from 'forms'; import { AccountActivationForm } from '@app/forms';
import './AccountActivationDialog.css'; import './AccountActivationDialog.css';

View file

@ -6,7 +6,7 @@ import IconButton from '@mui/material/IconButton';
import CloseIcon from '@mui/icons-material/Close'; import CloseIcon from '@mui/icons-material/Close';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import { CardImportForm } from 'forms'; import { CardImportForm } from '@app/forms';
import './CardImportDialog.css'; import './CardImportDialog.css';

View file

@ -8,7 +8,7 @@ import CloseIcon from '@mui/icons-material/Close';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { KnownHostForm } from 'forms'; import { KnownHostForm } from '@app/forms';
import './KnownHostDialog.css'; import './KnownHostDialog.css';

View file

@ -7,7 +7,7 @@ import CloseIcon from '@mui/icons-material/Close';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { RegisterForm } from 'forms'; import { RegisterForm } from '@app/forms';
import './RegistrationDialog.css'; import './RegistrationDialog.css';

View file

@ -7,7 +7,7 @@ import CloseIcon from '@mui/icons-material/Close';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { RequestPasswordResetForm } from 'forms'; import { RequestPasswordResetForm } from '@app/forms';
import './RequestPasswordResetDialog.css'; import './RequestPasswordResetDialog.css';

View file

@ -7,7 +7,7 @@ import CloseIcon from '@mui/icons-material/Close';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { ResetPasswordForm } from 'forms'; import { ResetPasswordForm } from '@app/forms';
import './ResetPasswordDialog.css'; import './ResetPasswordDialog.css';

View file

@ -6,9 +6,9 @@ import { useTranslation } from 'react-i18next';
import Button from '@mui/material/Button'; import Button from '@mui/material/Button';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import { InputField } from 'components'; import { InputField } from '@app/components';
import { useReduxEffect } from 'hooks'; import { useReduxEffect } from '@app/hooks';
import { ServerTypes } from 'store'; import { ServerTypes } from '@app/store';
import './AccountActivationForm.css'; import './AccountActivationForm.css';

View file

@ -8,8 +8,8 @@ import Step from '@mui/material/Step';
import StepLabel from '@mui/material/StepLabel'; import StepLabel from '@mui/material/StepLabel';
import CircularProgress from '@mui/material/CircularProgress'; import CircularProgress from '@mui/material/CircularProgress';
import { InputField, VirtualList } from 'components'; import { InputField, VirtualList } from '@app/components';
import { cardImporterService, CardDTO, SetDTO, TokenDTO } from 'services'; import { cardImporterService, CardDTO, SetDTO, TokenDTO } from '@app/services';
import './CardImportForm.css'; import './CardImportForm.css';

View file

@ -6,7 +6,7 @@ import { useTranslation } from 'react-i18next';
import Button from '@mui/material/Button'; import Button from '@mui/material/Button';
import AnchorLink from '@mui/material/Link'; import AnchorLink from '@mui/material/Link';
import { InputField } from 'components'; import { InputField } from '@app/components';
import './KnownHostForm.css'; import './KnownHostForm.css';

View file

@ -5,12 +5,11 @@ import { useTranslation } from 'react-i18next';
import Button from '@mui/material/Button'; import Button from '@mui/material/Button';
import { CheckboxField, InputField, KnownHosts } from 'components'; import { CheckboxField, InputField, KnownHosts } from '@app/components';
import { useAutoConnect } from 'hooks'; import { useAutoConnect } from '@app/hooks';
import { HostDTO, SettingDTO } from 'services'; import { HostDTO, SettingDTO } from '@app/services';
import { APP_USER } from 'types'; import { App } from '@app/types';
import { useAppSelector } from 'store'; import { useAppSelector, ServerSelectors } from '@app/store';
import { Selectors as ServerSelectors } from 'store/server';
import './LoginForm.css'; import './LoginForm.css';
@ -55,7 +54,7 @@ const LoginForm = ({ onSubmit, disableSubmitButton, onResetPassword }: LoginForm
const { values } = form.getState(); const { values } = form.getState();
useEffect(() => { useEffect(() => {
SettingDTO.get(APP_USER).then((userSetting: SettingDTO) => { SettingDTO.get(App.APP_USER).then((userSetting: SettingDTO) => {
if (userSetting?.autoConnect && !connectionAttemptMade) { if (userSetting?.autoConnect && !connectionAttemptMade) {
HostDTO.getAll().then(hosts => { HostDTO.getAll().then(hosts => {
let lastSelectedHost = hosts.find(({ lastSelected }) => lastSelected); let lastSelectedHost = hosts.find(({ lastSelected }) => lastSelected);

View file

@ -8,12 +8,12 @@ import { useTranslation } from 'react-i18next';
import Button from '@mui/material/Button'; import Button from '@mui/material/Button';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import { CountryDropdown, InputField, KnownHosts } from 'components'; import { CountryDropdown, InputField, KnownHosts } from '@app/components';
import { useReduxEffect } from 'hooks'; import { useReduxEffect } from '@app/hooks';
import { ServerDispatch, ServerSelectors, ServerTypes } from 'store'; import { ServerDispatch, ServerSelectors, ServerTypes } from '@app/store';
import './RegisterForm.css'; import './RegisterForm.css';
import { useToast } from 'components/Toast'; import { useToast } from '@app/components';
const RegisterForm = ({ onSubmit }: RegisterFormProps) => { const RegisterForm = ({ onSubmit }: RegisterFormProps) => {
const { t } = useTranslation(); const { t } = useTranslation();

View file

@ -7,9 +7,9 @@ import { useTranslation } from 'react-i18next';
import Button from '@mui/material/Button'; import Button from '@mui/material/Button';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import { InputField, KnownHosts } from 'components'; import { InputField, KnownHosts } from '@app/components';
import { useReduxEffect } from 'hooks'; import { useReduxEffect } from '@app/hooks';
import { ServerTypes } from 'store'; import { ServerTypes } from '@app/store';
import './RequestPasswordResetForm.css'; import './RequestPasswordResetForm.css';

View file

@ -6,9 +6,9 @@ import { useTranslation } from 'react-i18next';
import Button from '@mui/material/Button'; import Button from '@mui/material/Button';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import { InputField, KnownHosts } from 'components'; import { InputField, KnownHosts } from '@app/components';
import { useReduxEffect } from '../../hooks'; import { useReduxEffect } from '@app/hooks';
import { ServerTypes } from '../../store'; import { ServerTypes } from '@app/store';
import './ResetPasswordForm.css'; import './ResetPasswordForm.css';

View file

@ -6,7 +6,7 @@ import Button from '@mui/material/Button';
import Divider from '@mui/material/Divider'; import Divider from '@mui/material/Divider';
import Paper from '@mui/material/Paper'; import Paper from '@mui/material/Paper';
import { InputField, CheckboxField } from 'components'; import { InputField, CheckboxField } from '@app/components';
import './SearchForm.css'; import './SearchForm.css';

View file

@ -1,16 +1,16 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { SettingDTO } from 'services'; import { SettingDTO } from '@app/services';
import { APP_USER } from 'types'; import { App } from '@app/types';
export function useAutoConnect() { export function useAutoConnect() {
const [setting, setSetting] = useState(undefined); const [setting, setSetting] = useState(undefined);
const [autoConnect, setAutoConnect] = useState(undefined); const [autoConnect, setAutoConnect] = useState(undefined);
useEffect(() => { useEffect(() => {
SettingDTO.get(APP_USER).then((setting: SettingDTO) => { SettingDTO.get(App.APP_USER).then((setting: SettingDTO) => {
if (!setting) { if (!setting) {
setting = new SettingDTO(APP_USER); setting = new SettingDTO(App.APP_USER);
setting.save(); setting.save();
} }

View file

@ -1,18 +1,18 @@
import { ModuleType } from 'i18next'; import { ModuleType } from 'i18next';
import { Language } from 'types'; import { App } from '@app/types';
class I18nBackend { class I18nBackend {
static type: ModuleType = 'backend'; static type: ModuleType = 'backend';
static BASE_URL = `${import.meta.env.BASE_URL}locales`; static BASE_URL = `${import.meta.env.BASE_URL}locales`;
read(language, namespace, callback) { read(language, namespace, callback) {
if (!Language[language]) { if (!language[App.Language]) {
callback(true, null); callback(true, null);
return; return;
} }
fetch(`${I18nBackend.BASE_URL}/${Language[language]}/${namespace}.json`) fetch(`${I18nBackend.BASE_URL}/${language[App.Language]}/${namespace}.json`)
.then(resp => resp.json().then(json => callback(null, json))) .then(resp => resp.json().then(json => callback(null, json)))
.catch(error => callback(error, null)); .catch(error => callback(error, null));
} }

View file

@ -3,7 +3,7 @@ import LanguageDetector from 'i18next-browser-languagedetector';
import ICU from 'i18next-icu'; import ICU from 'i18next-icu';
import { initReactI18next } from 'react-i18next'; import { initReactI18next } from 'react-i18next';
import { Language } from 'types'; import { App } from '@app/types';
import I18nBackend from './i18n-backend'; import I18nBackend from './i18n-backend';
@ -17,9 +17,9 @@ i18n
.use(initReactI18next) .use(initReactI18next)
// for all options read: https://www.i18next.com/overview/configuration-options // for all options read: https://www.i18next.com/overview/configuration-options
.init({ .init({
fallbackLng: Language['en-US'], fallbackLng: App.Language['en-US'],
resources: { resources: {
[Language['en-US']]: { translation }, [App.Language['en-US']]: { translation },
}, },
partialBundledLanguages: true, partialBundledLanguages: true,

View file

@ -1,9 +1,13 @@
// MUST be first: installs BigInt.prototype.toJSON before any module that
// creates the Redux store or connects to Redux DevTools.
import './polyfills';
import { StrictMode } 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 { AppShell } from './containers'; import { AppShell } from '@app/containers';
import { materialTheme } from './material-theme'; import { materialTheme } from './material-theme';
import './i18n'; import './i18n';

View file

@ -0,0 +1,19 @@
// Runtime polyfills that must execute before any other application module.
// Import this file first from `src/index.tsx`.
// ── BigInt.prototype.toJSON ───────────────────────────────────────────────────
// Protobuf-ES maps proto `int64`/`uint64` fields to native `BigInt`. Those
// land in Redux state (e.g. `ServerInfo_User.accountageSecs`,
// `Response_Register.deniedEndTime`, the outbound `cmdId`), and any consumer
// that JSON-stringifies state — notably the Redux DevTools browser
// extension, but also logging and error-boundary dumps — throws with
// "Do not know how to serialize a BigInt" because `BigInt.prototype` has no
// `toJSON`. Installing one globally makes `JSON.stringify` coerce
// `BigInt → string` instead of throwing. Coercion is lossy but only affects
// serialized representations; the in-memory Redux state still holds real
// `BigInt`s and every consumer reads them via the generated proto accessors.
(BigInt.prototype as unknown as { toJSON: () => string }).toJSON = function bigIntToJSON() {
return this.toString();
};
export {};

View file

@ -1,8 +1,8 @@
import { Card } from 'types'; import { App } from '@app/types';
import { dexieService } from '../DexieService'; import { dexieService } from '../DexieService';
export class CardDTO extends Card { export class CardDTO extends App.Card {
save() { save() {
return dexieService.cards.put(this); return dexieService.cards.put(this);
} }

View file

@ -1,14 +1,14 @@
import { IndexableType } from 'dexie'; import { IndexableType } from 'dexie';
import { Host } from 'types'; import { App } from '@app/types';
import { dexieService } from '../DexieService'; import { dexieService } from '../DexieService';
export class HostDTO extends Host { export class HostDTO extends App.Host {
save() { save() {
return dexieService.hosts.put(this); return dexieService.hosts.put(this);
} }
static add(host: Host): Promise<IndexableType> { static add(host: App.Host): Promise<IndexableType> {
return dexieService.hosts.add(host); return dexieService.hosts.add(host);
} }
@ -20,7 +20,7 @@ export class HostDTO extends Host {
return dexieService.hosts.toArray(); return dexieService.hosts.toArray();
} }
static bulkAdd(hosts: Host[]): Promise<IndexableType> { static bulkAdd(hosts: App.Host[]): Promise<IndexableType> {
return dexieService.hosts.bulkAdd(hosts); return dexieService.hosts.bulkAdd(hosts);
} }

View file

@ -1,8 +1,8 @@
import { Set } from 'types'; import { App } from '@app/types';
import { dexieService } from '../DexieService'; import { dexieService } from '../DexieService';
export class SetDTO extends Set { export class SetDTO extends App.Set {
save() { save() {
return dexieService.sets.put(this); return dexieService.sets.put(this);
} }

View file

@ -1,8 +1,8 @@
import { Setting } from 'types'; import { App } from '@app/types';
import { dexieService } from '../DexieService'; import { dexieService } from '../DexieService';
export class SettingDTO extends Setting { export class SettingDTO extends App.Setting {
constructor(user) { constructor(user) {
super(); super();

View file

@ -1,8 +1,8 @@
import { Token } from 'types'; import { App } from '@app/types';
import { dexieService } from '../DexieService'; import { dexieService } from '../DexieService';
export class TokenDTO extends Token { export class TokenDTO extends App.Token {
save() { save() {
return dexieService.tokens.put(this); return dexieService.tokens.put(this);
} }

View file

@ -1,9 +1,40 @@
// ensure jest-dom is always available during testing to cut down on boilerplate // Install runtime polyfills (BigInt.prototype.toJSON) before any module
// under test loads — matches the production boot order in src/index.tsx.
import './polyfills';
// Ensure jest-dom matchers are available in every test file.
import '@testing-library/jest-dom/vitest'; import '@testing-library/jest-dom/vitest';
// With isolate: false, all test files share the same module context. // ── Global mock hygiene under `isolate: false` ────────────────────────────────
// Restore all mocks/spies after each test to prevent leakage between tests. //
// Vitest is configured with `test.isolate: false` for speed — every spec file
// in a worker shares the same module graph and the same `vi.mock` factories.
// Without aggressive per-test cleanup, state leaks trivially between tests:
//
// - A test accumulates `.mock.calls` on a shared `vi.fn()`. Later tests
// either see stale history or accidentally match on prior invocations.
// - A test installs `vi.spyOn` on a real method. Without restore, the spy
// persists into every following test and file.
// - A test swaps to fake timers. Real-time code in later tests hangs.
//
// `vi.clearAllMocks()` clears `.mock.calls` on every tracked mock without
// touching implementations — safe for module factories that produce `vi.fn()`
// instances at the top of a spec file and rely on those instances sticking
// around. `vi.restoreAllMocks()` restores original implementations on
// `vi.spyOn` targets. `vi.useRealTimers()` drops any fake-timer installation.
//
// NOTE: we intentionally do NOT call `vi.resetAllMocks()` — it resets the
// implementations of `vi.fn()` instances created inside `vi.mock(...)`
// factories, which breaks any spec that expects those mocks to persist
// across tests in the same file (e.g. `store.dispatch` mocked once at file
// load).
//
// If a specific test needs to install its own `mockReturnValue` /
// `mockImplementation`, it should set it in that test's body and rely on
// the next test overwriting or the global `clearAllMocks` clearing calls —
// it should NOT assume the mock is reset to its factory default automatically.
afterEach(() => { afterEach(() => {
vi.clearAllMocks();
vi.restoreAllMocks(); vi.restoreAllMocks();
vi.useRealTimers(); vi.useRealTimers();
}); });

View file

@ -1,6 +1,5 @@
import { create } from '@bufbuild/protobuf'; import { create } from '@bufbuild/protobuf';
import { SortDirection } from 'types'; import { App, Data } from '@app/types';
import { ServerInfo_UserSchema } from 'generated/proto/serverinfo_user_pb';
import SortUtil from './SortUtil'; import SortUtil from './SortUtil';
// ── sortByField ─────────────────────────────────────────────────────────────── // ── sortByField ───────────────────────────────────────────────────────────────
@ -8,48 +7,48 @@ import SortUtil from './SortUtil';
describe('sortByField', () => { describe('sortByField', () => {
it('sorts string field ASC alphabetically', () => { it('sorts string field ASC alphabetically', () => {
const arr = [{ name: 'Zane' }, { name: 'Alice' }, { name: 'Bob' }]; const arr = [{ name: 'Zane' }, { name: 'Alice' }, { name: 'Bob' }];
SortUtil.sortByField(arr, { field: 'name', order: SortDirection.ASC }); SortUtil.sortByField(arr, { field: 'name', order: App.SortDirection.ASC });
expect(arr.map(x => x.name)).toEqual(['Alice', 'Bob', 'Zane']); expect(arr.map(x => x.name)).toEqual(['Alice', 'Bob', 'Zane']);
}); });
it('sorts string field DESC reverse-alphabetically', () => { it('sorts string field DESC reverse-alphabetically', () => {
const arr = [{ name: 'Alice' }, { name: 'Zane' }, { name: 'Bob' }]; const arr = [{ name: 'Alice' }, { name: 'Zane' }, { name: 'Bob' }];
SortUtil.sortByField(arr, { field: 'name', order: SortDirection.DESC }); SortUtil.sortByField(arr, { field: 'name', order: App.SortDirection.DESC });
expect(arr.map(x => x.name)).toEqual(['Zane', 'Bob', 'Alice']); expect(arr.map(x => x.name)).toEqual(['Zane', 'Bob', 'Alice']);
}); });
it('sorts number field ASC', () => { it('sorts number field ASC', () => {
const arr = [{ score: 30 }, { score: 10 }, { score: 20 }]; const arr = [{ score: 30 }, { score: 10 }, { score: 20 }];
SortUtil.sortByField(arr, { field: 'score', order: SortDirection.ASC }); SortUtil.sortByField(arr, { field: 'score', order: App.SortDirection.ASC });
expect(arr.map(x => x.score)).toEqual([10, 20, 30]); expect(arr.map(x => x.score)).toEqual([10, 20, 30]);
}); });
it('sorts number field DESC', () => { it('sorts number field DESC', () => {
const arr = [{ score: 10 }, { score: 30 }, { score: 20 }]; const arr = [{ score: 10 }, { score: 30 }, { score: 20 }];
SortUtil.sortByField(arr, { field: 'score', order: SortDirection.DESC }); SortUtil.sortByField(arr, { field: 'score', order: App.SortDirection.DESC });
expect(arr.map(x => x.score)).toEqual([30, 20, 10]); expect(arr.map(x => x.score)).toEqual([30, 20, 10]);
}); });
it('no-ops on empty array without error', () => { it('no-ops on empty array without error', () => {
expect(() => SortUtil.sortByField([], { field: 'name', order: SortDirection.ASC })).not.toThrow(); expect(() => SortUtil.sortByField([], { field: 'name', order: App.SortDirection.ASC })).not.toThrow();
}); });
it('sorts with nested dot-notation field', () => { it('sorts with nested dot-notation field', () => {
const arr = [{ meta: { rank: 3 } }, { meta: { rank: 1 } }, { meta: { rank: 2 } }]; const arr = [{ meta: { rank: 3 } }, { meta: { rank: 1 } }, { meta: { rank: 2 } }];
SortUtil.sortByField(arr, { field: 'meta.rank', order: SortDirection.ASC }); SortUtil.sortByField(arr, { field: 'meta.rank', order: App.SortDirection.ASC });
expect(arr.map(x => x.meta.rank)).toEqual([1, 2, 3]); expect(arr.map(x => x.meta.rank)).toEqual([1, 2, 3]);
}); });
it('throws when field resolves to a non-string, non-number value', () => { it('throws when field resolves to a non-string, non-number value', () => {
const arr = [{ data: {} }, { data: {} }]; const arr = [{ data: {} }, { data: {} }];
expect(() => SortUtil.sortByField(arr, { field: 'data', order: SortDirection.ASC })).toThrow( expect(() => SortUtil.sortByField(arr, { field: 'data', order: App.SortDirection.ASC })).toThrow(
'SortField must resolve to either a string or number' 'SortField must resolve to either a string or number'
); );
}); });
it('sorts empty-string values to the bottom when sorting ASC', () => { it('sorts empty-string values to the bottom when sorting ASC', () => {
const arr = [{ name: '' }, { name: 'Alice' }, { name: '' }]; const arr = [{ name: '' }, { name: 'Alice' }, { name: '' }];
SortUtil.sortByField(arr, { field: 'name', order: SortDirection.ASC }); SortUtil.sortByField(arr, { field: 'name', order: App.SortDirection.ASC });
expect(arr[0].name).toBe('Alice'); expect(arr[0].name).toBe('Alice');
expect(arr[1].name).toBe(''); expect(arr[1].name).toBe('');
expect(arr[2].name).toBe(''); expect(arr[2].name).toBe('');
@ -66,8 +65,8 @@ describe('sortByFields', () => {
{ group: 'B', name: 'Alice' }, { group: 'B', name: 'Alice' },
]; ];
SortUtil.sortByFields(arr, [ SortUtil.sortByFields(arr, [
{ field: 'group', order: SortDirection.ASC }, { field: 'group', order: App.SortDirection.ASC },
{ field: 'name', order: SortDirection.ASC }, { field: 'name', order: App.SortDirection.ASC },
]); ]);
expect(arr.map(x => x.group)).toEqual(['A', 'B', 'C']); expect(arr.map(x => x.group)).toEqual(['A', 'B', 'C']);
}); });
@ -79,8 +78,8 @@ describe('sortByFields', () => {
{ group: 'B', name: 'Bob' }, { group: 'B', name: 'Bob' },
]; ];
SortUtil.sortByFields(arr, [ SortUtil.sortByFields(arr, [
{ field: 'group', order: SortDirection.ASC }, { field: 'group', order: App.SortDirection.ASC },
{ field: 'name', order: SortDirection.ASC }, { field: 'name', order: App.SortDirection.ASC },
]); ]);
expect(arr[0]).toEqual({ group: 'A', name: 'Alice' }); expect(arr[0]).toEqual({ group: 'A', name: 'Alice' });
expect(arr[1]).toEqual({ group: 'A', name: 'Zane' }); expect(arr[1]).toEqual({ group: 'A', name: 'Zane' });
@ -89,20 +88,20 @@ describe('sortByFields', () => {
it('no-ops on empty array', () => { it('no-ops on empty array', () => {
expect(() => expect(() =>
SortUtil.sortByFields([], [{ field: 'name', order: SortDirection.ASC }]) SortUtil.sortByFields([], [{ field: 'name', order: App.SortDirection.ASC }])
).not.toThrow(); ).not.toThrow();
}); });
it('sorts by number field', () => { it('sorts by number field', () => {
const arr = [{ score: 3 }, { score: 1 }, { score: 2 }]; const arr = [{ score: 3 }, { score: 1 }, { score: 2 }];
SortUtil.sortByFields(arr, [{ field: 'score', order: SortDirection.ASC }]); SortUtil.sortByFields(arr, [{ field: 'score', order: App.SortDirection.ASC }]);
expect(arr.map(x => x.score)).toEqual([1, 2, 3]); expect(arr.map(x => x.score)).toEqual([1, 2, 3]);
}); });
it('returns 0 when all items tie on every sort key', () => { it('returns 0 when all items tie on every sort key', () => {
const arr = [{ score: 5 }, { score: 5 }]; const arr = [{ score: 5 }, { score: 5 }];
expect(() => expect(() =>
SortUtil.sortByFields(arr, [{ field: 'score', order: SortDirection.ASC }]) SortUtil.sortByFields(arr, [{ field: 'score', order: App.SortDirection.ASC }])
).not.toThrow(); ).not.toThrow();
expect(arr).toHaveLength(2); expect(arr).toHaveLength(2);
}); });
@ -110,7 +109,7 @@ describe('sortByFields', () => {
it('throws when field resolves to a non-string, non-number value', () => { it('throws when field resolves to a non-string, non-number value', () => {
const arr = [{ data: {} }, { data: {} }]; const arr = [{ data: {} }, { data: {} }];
expect(() => expect(() =>
SortUtil.sortByFields(arr, [{ field: 'data', order: SortDirection.ASC }]) SortUtil.sortByFields(arr, [{ field: 'data', order: App.SortDirection.ASC }])
).toThrow('SortField must resolve to either a string or number'); ).toThrow('SortField must resolve to either a string or number');
}); });
}); });
@ -120,11 +119,11 @@ describe('sortByFields', () => {
describe('sortUsersByField', () => { describe('sortUsersByField', () => {
it('sorts by userLevel DESC first, then name ASC', () => { it('sorts by userLevel DESC first, then name ASC', () => {
const users = [ const users = [
create(ServerInfo_UserSchema, { name: 'Alice', userLevel: 1, accountageSecs: 0n, privlevel: '' }), create(Data.ServerInfo_UserSchema, { name: 'Alice', userLevel: 1, accountageSecs: 0n, privlevel: '' }),
create(ServerInfo_UserSchema, { name: 'Bob', userLevel: 8, accountageSecs: 0n, privlevel: '' }), create(Data.ServerInfo_UserSchema, { name: 'Bob', userLevel: 8, accountageSecs: 0n, privlevel: '' }),
create(ServerInfo_UserSchema, { name: 'Carol', userLevel: 1, accountageSecs: 0n, privlevel: '' }), create(Data.ServerInfo_UserSchema, { name: 'Carol', userLevel: 1, accountageSecs: 0n, privlevel: '' }),
]; ];
SortUtil.sortUsersByField(users, { field: 'name', order: SortDirection.ASC }); SortUtil.sortUsersByField(users, { field: 'name', order: App.SortDirection.ASC });
expect(users[0].name).toBe('Bob'); expect(users[0].name).toBe('Bob');
expect(users[1].name).toBe('Alice'); expect(users[1].name).toBe('Alice');
expect(users[2].name).toBe('Carol'); expect(users[2].name).toBe('Carol');
@ -132,17 +131,17 @@ describe('sortUsersByField', () => {
it('no-ops on empty array', () => { it('no-ops on empty array', () => {
expect(() => expect(() =>
SortUtil.sortUsersByField([], { field: 'name', order: SortDirection.ASC }) SortUtil.sortUsersByField([], { field: 'name', order: App.SortDirection.ASC })
).not.toThrow(); ).not.toThrow();
}); });
it('returns 0 (stable) when two users tie on both userLevel and name', () => { it('returns 0 (stable) when two users tie on both userLevel and name', () => {
const users = [ const users = [
create(ServerInfo_UserSchema, { name: 'Alice', userLevel: 1, accountageSecs: 0n, privlevel: '' }), create(Data.ServerInfo_UserSchema, { name: 'Alice', userLevel: 1, accountageSecs: 0n, privlevel: '' }),
create(ServerInfo_UserSchema, { name: 'Alice', userLevel: 1, accountageSecs: 0n, privlevel: '' }), create(Data.ServerInfo_UserSchema, { name: 'Alice', userLevel: 1, accountageSecs: 0n, privlevel: '' }),
]; ];
expect(() => expect(() =>
SortUtil.sortUsersByField(users, { field: 'name', order: SortDirection.ASC }) SortUtil.sortUsersByField(users, { field: 'name', order: App.SortDirection.ASC })
).not.toThrow(); ).not.toThrow();
expect(users).toHaveLength(2); expect(users).toHaveLength(2);
}); });
@ -152,18 +151,18 @@ describe('sortUsersByField', () => {
describe('toggleSortBy', () => { describe('toggleSortBy', () => {
it('same field + ASC → returns DESC', () => { it('same field + ASC → returns DESC', () => {
const result = SortUtil.toggleSortBy('name', { field: 'name', order: SortDirection.ASC }); const result = SortUtil.toggleSortBy('name', { field: 'name', order: App.SortDirection.ASC });
expect(result).toEqual({ field: 'name', order: SortDirection.DESC }); expect(result).toEqual({ field: 'name', order: App.SortDirection.DESC });
}); });
it('same field + DESC → returns ASC', () => { it('same field + DESC → returns ASC', () => {
const result = SortUtil.toggleSortBy('name', { field: 'name', order: SortDirection.DESC }); const result = SortUtil.toggleSortBy('name', { field: 'name', order: App.SortDirection.DESC });
expect(result).toEqual({ field: 'name', order: SortDirection.ASC }); expect(result).toEqual({ field: 'name', order: App.SortDirection.ASC });
}); });
it('different field → returns ASC regardless of current order', () => { it('different field → returns ASC regardless of current order', () => {
const result = SortUtil.toggleSortBy('score', { field: 'name', order: SortDirection.DESC }); const result = SortUtil.toggleSortBy('score', { field: 'name', order: App.SortDirection.DESC });
expect(result).toEqual({ field: 'score', order: SortDirection.ASC }); expect(result).toEqual({ field: 'score', order: App.SortDirection.ASC });
}); });
}); });
@ -173,7 +172,7 @@ describe('resolveFieldChain via sortByField (numeric index)', () => {
it('resolves numeric index in dot-notation chain', () => { it('resolves numeric index in dot-notation chain', () => {
const arr = [{ items: ['c', 'b', 'a'] }, { items: ['z', 'y', 'x'] }]; const arr = [{ items: ['c', 'b', 'a'] }, { items: ['z', 'y', 'x'] }];
// Sort by items.0 which is the first element of the items array // Sort by items.0 which is the first element of the items array
SortUtil.sortByField(arr, { field: 'items.0', order: SortDirection.ASC }); SortUtil.sortByField(arr, { field: 'items.0', order: App.SortDirection.ASC });
expect(arr[0].items[0]).toBe('c'); expect(arr[0].items[0]).toBe('c');
expect(arr[1].items[0]).toBe('z'); expect(arr[1].items[0]).toBe('z');
}); });

View file

@ -1,8 +1,7 @@
import { SortBy, SortDirection } from 'types'; import { App, Data } from '@app/types';
import type { ServerInfo_User } from 'generated/proto/serverinfo_user_pb';
export default class SortUtil { export default class SortUtil {
static sortByField<T extends object>(arr: T[], sortBy: SortBy): void { static sortByField<T extends object>(arr: T[], sortBy: App.SortBy): void {
if (arr.length) { if (arr.length) {
const field = SortUtil.resolveFieldChain(arr[0], sortBy.field); const field = SortUtil.resolveFieldChain(arr[0], sortBy.field);
const fieldType = typeof field; const fieldType = typeof field;
@ -21,7 +20,7 @@ export default class SortUtil {
} }
} }
static sortByFields<T extends object>(arr: T[], sorts: SortBy[]) { static sortByFields<T extends object>(arr: T[], sorts: App.SortBy[]) {
if (arr.length) { if (arr.length) {
arr.sort((a, b) => { arr.sort((a, b) => {
for (let i = 0; i < sorts.length; i++) { for (let i = 0; i < sorts.length; i++) {
@ -52,35 +51,35 @@ export default class SortUtil {
} }
} }
static sortUsersByField(users: ServerInfo_User[], sortBy: SortBy) { static sortUsersByField(users: Data.ServerInfo_User[], sortBy: App.SortBy) {
if (users.length) { if (users.length) {
users.sort((a, b) => SortUtil.userComparator(a, b, sortBy)) users.sort((a, b) => SortUtil.userComparator(a, b, sortBy))
} }
} }
static toggleSortBy<F extends string>(field: F, sortBy: SortBy): { field: F; order: SortDirection } { static toggleSortBy<F extends string>(field: F, sortBy: App.SortBy): { field: F; order: App.SortDirection } {
const sameField = field === sortBy.field; const sameField = field === sortBy.field;
const isASC = sortBy.order === SortDirection.ASC; const isASC = sortBy.order === App.SortDirection.ASC;
return { return {
field, field,
order: sameField && isASC ? SortDirection.DESC : SortDirection.ASC order: sameField && isASC ? App.SortDirection.DESC : App.SortDirection.ASC
} }
} }
private static sortByNumber<T extends object>(arr: T[], sortBy: SortBy): void { private static sortByNumber<T extends object>(arr: T[], sortBy: App.SortBy): void {
arr.sort((a, b) => SortUtil.numberComparator(a, b, sortBy)); arr.sort((a, b) => SortUtil.numberComparator(a, b, sortBy));
} }
private static sortByString<T extends object>(arr: T[], sortBy: SortBy): void { private static sortByString<T extends object>(arr: T[], sortBy: App.SortBy): void {
arr.sort((a, b) => SortUtil.stringComparator(a, b, sortBy)); arr.sort((a, b) => SortUtil.stringComparator(a, b, sortBy));
} }
private static userComparator(a: ServerInfo_User, b: ServerInfo_User, sortBy: SortBy, sortByUserLevel = true) { private static userComparator(a: Data.ServerInfo_User, b: Data.ServerInfo_User, sortBy: App.SortBy, sortByUserLevel = true) {
if (sortByUserLevel) { if (sortByUserLevel) {
const adminSortBy = { const adminSortBy = {
field: 'userLevel', field: 'userLevel',
order: SortDirection.DESC order: App.SortDirection.DESC
}; };
const adminSorted = SortUtil.numberComparator(a, b, adminSortBy); const adminSorted = SortUtil.numberComparator(a, b, adminSortBy);
@ -99,18 +98,18 @@ export default class SortUtil {
return 0; return 0;
} }
private static numberComparator<T extends object>(a: T, b: T, { field, order }: SortBy) { private static numberComparator<T extends object>(a: T, b: T, { field, order }: App.SortBy) {
const aResolved = SortUtil.resolveFieldChain(a, field); const aResolved = SortUtil.resolveFieldChain(a, field);
const bResolved = SortUtil.resolveFieldChain(b, field); const bResolved = SortUtil.resolveFieldChain(b, field);
if (order === SortDirection.ASC) { if (order === App.SortDirection.ASC) {
return aResolved - bResolved; return aResolved - bResolved;
} else { } else {
return bResolved - aResolved; return bResolved - aResolved;
} }
} }
private static stringComparator<T extends object>(a: T, b: T, { field, order }: SortBy) { private static stringComparator<T extends object>(a: T, b: T, { field, order }: App.SortBy) {
const aResolved = SortUtil.resolveFieldChain(a, field); const aResolved = SortUtil.resolveFieldChain(a, field);
const bResolved = SortUtil.resolveFieldChain(b, field); const bResolved = SortUtil.resolveFieldChain(b, field);
@ -125,7 +124,7 @@ export default class SortUtil {
return -1; return -1;
} }
if (order === SortDirection.ASC) { if (order === App.SortDirection.ASC) {
return aResolved.localeCompare(bResolved); return aResolved.localeCompare(bResolved);
} else { } else {
return bResolved.localeCompare(aResolved); return bResolved.localeCompare(aResolved);

View file

@ -1,18 +1,15 @@
import { normalizeRoomInfo, normalizeGameObject, normalizeLogs, normalizeBannedUserError, normalizeUserMessage } from './normalizers'; import { normalizeRoomInfo, normalizeGameObject, normalizeLogs, normalizeBannedUserError, normalizeUserMessage } from './normalizers';
import { create } from '@bufbuild/protobuf'; import { create } from '@bufbuild/protobuf';
import { ServerInfo_RoomSchema } from 'generated/proto/serverinfo_room_pb'; import { Data, Enriched } from '@app/types';
import { ServerInfo_GameSchema } from 'generated/proto/serverinfo_game_pb';
import { Event_RoomSaySchema } from 'generated/proto/event_room_say_pb';
import { Message } from 'types';
describe('normalizeRoomInfo', () => { describe('normalizeRoomInfo', () => {
it('builds gametypeMap from gametypeList and normalises games', () => { it('builds gametypeMap from gametypeList and normalises games', () => {
const room = create(ServerInfo_RoomSchema, { const room = create(Data.ServerInfo_RoomSchema, {
roomId: 1, roomId: 1,
name: 'Lobby', name: 'Lobby',
gametypeList: [{ gameTypeId: 1, description: 'Standard' }], gametypeList: [{ gameTypeId: 1, description: 'Standard' }],
gameList: [ gameList: [
create(ServerInfo_GameSchema, { gameId: 10, gameTypes: [1], description: 'My Game' }), create(Data.ServerInfo_GameSchema, { gameId: 10, gameTypes: [1], description: 'My Game' }),
], ],
}); });
@ -25,7 +22,7 @@ describe('normalizeRoomInfo', () => {
}); });
it('handles room with empty gametypeList', () => { it('handles room with empty gametypeList', () => {
const room = create(ServerInfo_RoomSchema, { roomId: 2, name: 'Empty' }); const room = create(Data.ServerInfo_RoomSchema, { roomId: 2, name: 'Empty' });
const result = normalizeRoomInfo(room); const result = normalizeRoomInfo(room);
expect(result.gametypeMap).toEqual({}); expect(result.gametypeMap).toEqual({});
expect(result.gameList).toEqual([]); expect(result.gameList).toEqual([]);
@ -34,19 +31,19 @@ describe('normalizeRoomInfo', () => {
describe('normalizeGameObject', () => { describe('normalizeGameObject', () => {
it('maps gameTypes[0] to gameType string via gametypeMap', () => { it('maps gameTypes[0] to gameType string via gametypeMap', () => {
const game = create(ServerInfo_GameSchema, { gameId: 1, gameTypes: [5] }); const game = create(Data.ServerInfo_GameSchema, { gameId: 1, gameTypes: [5] });
const result = normalizeGameObject(game, { 5: 'Legacy' }); const result = normalizeGameObject(game, { 5: 'Legacy' });
expect(result.gameType).toBe('Legacy'); expect(result.gameType).toBe('Legacy');
}); });
it('returns empty string when no gameTypes', () => { it('returns empty string when no gameTypes', () => {
const game = create(ServerInfo_GameSchema, { gameId: 2 }); const game = create(Data.ServerInfo_GameSchema, { gameId: 2 });
const result = normalizeGameObject(game, {}); const result = normalizeGameObject(game, {});
expect(result.gameType).toBe(''); expect(result.gameType).toBe('');
}); });
it('fills empty description with empty string', () => { it('fills empty description with empty string', () => {
const game = create(ServerInfo_GameSchema, { gameId: 3 }); const game = create(Data.ServerInfo_GameSchema, { gameId: 3 });
const result = normalizeGameObject(game, {}); const result = normalizeGameObject(game, {});
expect(result.description).toBe(''); expect(result.description).toBe('');
}); });
@ -55,10 +52,10 @@ describe('normalizeGameObject', () => {
describe('normalizeLogs', () => { describe('normalizeLogs', () => {
it('groups logs by targetType', () => { it('groups logs by targetType', () => {
const logs = [ const logs = [
{ targetType: 'room' }, create(Data.ServerInfo_ChatMessageSchema, { targetType: 'room' }),
{ targetType: 'game' }, create(Data.ServerInfo_ChatMessageSchema, { targetType: 'game' }),
{ targetType: 'room' }, create(Data.ServerInfo_ChatMessageSchema, { targetType: 'room' }),
] as any[]; ];
const result = normalizeLogs(logs); const result = normalizeLogs(logs);
expect(result.room).toHaveLength(2); expect(result.room).toHaveLength(2);
expect(result.game).toHaveLength(1); expect(result.game).toHaveLength(1);
@ -71,7 +68,7 @@ describe('normalizeLogs', () => {
}); });
describe('normalizeBannedUserError', () => { describe('normalizeBannedUserError', () => {
it('returns permanently banned message when endTime is 0', () => { it('returns permanently banned Enriched.Message when endTime is 0', () => {
expect(normalizeBannedUserError('', 0)).toBe('You are permanently banned'); expect(normalizeBannedUserError('', 0)).toBe('You are permanently banned');
}); });
@ -92,11 +89,11 @@ describe('normalizeBannedUserError', () => {
}); });
describe('normalizeUserMessage', () => { describe('normalizeUserMessage', () => {
const makeMsg = (fields: Partial<Message>): Message => ({ const makeMsg = (fields: Partial<Enriched.Message>): Enriched.Message => ({
...create(Event_RoomSaySchema), ...create(Data.Event_RoomSaySchema),
timeReceived: 0, timeReceived: 0,
...fields, ...fields,
} as Message); } as Enriched.Message);
it('prepends "name: " to message when name is present', () => { it('prepends "name: " to message when name is present', () => {
const result = normalizeUserMessage(makeMsg({ name: 'Alice', message: 'hello' })); const result = normalizeUserMessage(makeMsg({ name: 'Alice', message: 'hello' }));

View file

@ -1,19 +1,15 @@
import type { ServerInfo_Room } from 'generated/proto/serverinfo_room_pb'; import { Data, Enriched } from '@app/types';
import type { ServerInfo_Game } from 'generated/proto/serverinfo_game_pb';
import type { ServerInfo_GameType } from 'generated/proto/serverinfo_gametype_pb';
import type { ServerInfo_ChatMessage } from 'generated/proto/serverinfo_chat_message_pb';
import { Game, GametypeMap, LogGroups, Message, Room } from 'types';
/** Flatten a gametype list into a lookup map of { gameTypeId → description }. */ /** Flatten a gametype list into a lookup map of { gameTypeId → description }. */
export function normalizeGametypeMap(gametypeList: ServerInfo_GameType[]): GametypeMap { export function normalizeGametypeMap(gametypeList: Data.ServerInfo_GameType[]): Enriched.GametypeMap {
return gametypeList.reduce<GametypeMap>((map, type) => { return gametypeList.reduce<Enriched.GametypeMap>((map, type) => {
map[type.gameTypeId] = type.description; map[type.gameTypeId] = type.description;
return map; return map;
}, {}); }, {});
} }
/** Flatten room gameTypes into a map object and normalize all games inside. */ /** Flatten room gameTypes into a map object and normalize all games inside. */
export function normalizeRoomInfo(roomInfo: ServerInfo_Room): Room { export function normalizeRoomInfo(roomInfo: Data.ServerInfo_Room): Enriched.Room {
const gametypeMap = normalizeGametypeMap(roomInfo.gametypeList); const gametypeMap = normalizeGametypeMap(roomInfo.gametypeList);
const gameList = roomInfo.gameList.map( const gameList = roomInfo.gameList.map(
@ -29,7 +25,7 @@ export function normalizeRoomInfo(roomInfo: ServerInfo_Room): Room {
} }
/** Flatten gameTypes[] into a gameType string; fill in default sortable values. */ /** Flatten gameTypes[] into a gameType string; fill in default sortable values. */
export function normalizeGameObject(game: ServerInfo_Game, gametypeMap: GametypeMap): Game { export function normalizeGameObject(game: Data.ServerInfo_Game, gametypeMap: Enriched.GametypeMap): Enriched.Game {
const { gameTypes, description } = game; const { gameTypes, description } = game;
const hasType = gameTypes && gameTypes.length; const hasType = gameTypes && gameTypes.length;
@ -41,13 +37,13 @@ export function normalizeGameObject(game: ServerInfo_Game, gametypeMap: Gametype
} }
/** Group a flat LogItem[] into { room, game, chat } buckets for the server store. */ /** Group a flat LogItem[] into { room, game, chat } buckets for the server store. */
export function normalizeLogs(logs: ServerInfo_ChatMessage[]): LogGroups { export function normalizeLogs(logs: Data.ServerInfo_ChatMessage[]): Enriched.LogGroups {
return logs.reduce((obj, log) => { return logs.reduce((obj, log) => {
const type = log.targetType as keyof LogGroups; const type = log.targetType as keyof Enriched.LogGroups;
obj[type] = obj[type] || []; obj[type] = obj[type] || [];
obj[type]!.push(log); obj[type]!.push(log);
return obj; return obj;
}, {} as LogGroups); }, {} as Enriched.LogGroups);
} }
/** /**
@ -56,7 +52,7 @@ export function normalizeLogs(logs: ServerInfo_ChatMessage[]): LogGroups {
* so this is a no-op for those. * so this is a no-op for those.
* Returns a new Message does not mutate the original. * Returns a new Message does not mutate the original.
*/ */
export function normalizeUserMessage(message: Message): Message { export function normalizeUserMessage(message: Enriched.Message): Enriched.Message {
if (!message.name) { if (!message.name) {
return message; return message;
} }

View file

@ -1,18 +1,10 @@
import { ProtoInit } from 'types'; import type { MessageInitShape } from '@bufbuild/protobuf';
import type { ServerInfo_Card } from 'generated/proto/serverinfo_card_pb'; import { Data } from '@app/types';
import type { ServerInfo_Counter } from 'generated/proto/serverinfo_counter_pb';
import type { ServerInfo_Arrow } from 'generated/proto/serverinfo_arrow_pb';
import type { ServerInfo_PlayerProperties } from 'generated/proto/serverinfo_playerproperties_pb';
import { create } from '@bufbuild/protobuf'; import { create } from '@bufbuild/protobuf';
import { ServerInfo_CardSchema } from 'generated/proto/serverinfo_card_pb';
import { ServerInfo_CounterSchema } from 'generated/proto/serverinfo_counter_pb';
import { colorSchema } from 'generated/proto/color_pb';
import { ServerInfo_ArrowSchema } from 'generated/proto/serverinfo_arrow_pb';
import { ServerInfo_PlayerPropertiesSchema } from 'generated/proto/serverinfo_playerproperties_pb';
import { GameEntry, GamesState, PlayerEntry, ZoneEntry } from '../game.interfaces'; import { GameEntry, GamesState, PlayerEntry, ZoneEntry } from '../game.interfaces';
export function makeCard(overrides: ProtoInit<ServerInfo_Card> = {}): ServerInfo_Card { export function makeCard(overrides: MessageInitShape<typeof Data.ServerInfo_CardSchema> = {}): Data.ServerInfo_Card {
return create(ServerInfo_CardSchema, { return create(Data.ServerInfo_CardSchema, {
id: 1, id: 1,
name: 'Test Card', name: 'Test Card',
x: 0, x: 0,
@ -34,19 +26,19 @@ export function makeCard(overrides: ProtoInit<ServerInfo_Card> = {}): ServerInfo
}); });
} }
export function makeCounter(overrides: ProtoInit<ServerInfo_Counter> = {}): ServerInfo_Counter { export function makeCounter(overrides: MessageInitShape<typeof Data.ServerInfo_CounterSchema> = {}): Data.ServerInfo_Counter {
return create(ServerInfo_CounterSchema, { return create(Data.ServerInfo_CounterSchema, {
id: 1, id: 1,
name: 'Life', name: 'Life',
counterColor: create(colorSchema, { r: 0, g: 0, b: 0, a: 255 }), counterColor: create(Data.colorSchema, { r: 0, g: 0, b: 0, a: 255 }),
radius: 1, radius: 1,
count: 20, count: 20,
...overrides, ...overrides,
}); });
} }
export function makeArrow(overrides: ProtoInit<ServerInfo_Arrow> = {}): ServerInfo_Arrow { export function makeArrow(overrides: MessageInitShape<typeof Data.ServerInfo_ArrowSchema> = {}): Data.ServerInfo_Arrow {
return create(ServerInfo_ArrowSchema, { return create(Data.ServerInfo_ArrowSchema, {
id: 1, id: 1,
startPlayerId: 1, startPlayerId: 1,
startZone: 'table', startZone: 'table',
@ -54,7 +46,7 @@ export function makeArrow(overrides: ProtoInit<ServerInfo_Arrow> = {}): ServerIn
targetPlayerId: 1, targetPlayerId: 1,
targetZone: 'table', targetZone: 'table',
targetCardId: 2, targetCardId: 2,
arrowColor: create(colorSchema, { r: 255, g: 0, b: 0, a: 255 }), arrowColor: create(Data.colorSchema, { r: 255, g: 0, b: 0, a: 255 }),
...overrides, ...overrides,
}); });
} }
@ -72,8 +64,10 @@ export function makeZoneEntry(overrides: Partial<ZoneEntry> = {}): ZoneEntry {
}; };
} }
export function makePlayerProperties(overrides: ProtoInit<ServerInfo_PlayerProperties> = {}): ServerInfo_PlayerProperties { export function makePlayerProperties(
return create(ServerInfo_PlayerPropertiesSchema, { overrides: MessageInitShape<typeof Data.ServerInfo_PlayerPropertiesSchema> = {},
): Data.ServerInfo_PlayerProperties {
return create(Data.ServerInfo_PlayerPropertiesSchema, {
playerId: 1, playerId: 1,
spectator: false, spectator: false,
conceded: false, conceded: false,

View file

@ -1,32 +1,13 @@
import { create } from '@bufbuild/protobuf'; import { create } from '@bufbuild/protobuf';
import { Data } from '@app/types';
import { Actions } from './game.actions'; import { Actions } from './game.actions';
import { Types } from './game.types'; import { Types } from './game.types';
import { import {
makeArrow, makeArrow,
makeCard, makeCard,
makeCounter, makeCounter,
makeGameEntry,
makePlayerProperties, makePlayerProperties,
} from './__mocks__/fixtures'; } from './__mocks__/fixtures';
import { Event_GameStateChangedSchema } from 'generated/proto/event_game_state_changed_pb';
import { Event_MoveCardSchema } from 'generated/proto/event_move_card_pb';
import { Event_FlipCardSchema } from 'generated/proto/event_flip_card_pb';
import { Event_DestroyCardSchema } from 'generated/proto/event_destroy_card_pb';
import { Event_AttachCardSchema } from 'generated/proto/event_attach_card_pb';
import { Event_CreateTokenSchema } from 'generated/proto/event_create_token_pb';
import { Event_SetCardAttrSchema } from 'generated/proto/event_set_card_attr_pb';
import { Event_SetCardCounterSchema } from 'generated/proto/event_set_card_counter_pb';
import { Event_CreateArrowSchema } from 'generated/proto/event_create_arrow_pb';
import { Event_DeleteArrowSchema } from 'generated/proto/event_delete_arrow_pb';
import { Event_CreateCounterSchema } from 'generated/proto/event_create_counter_pb';
import { Event_SetCounterSchema } from 'generated/proto/event_set_counter_pb';
import { Event_DelCounterSchema } from 'generated/proto/event_del_counter_pb';
import { Event_DrawCardsSchema } from 'generated/proto/event_draw_cards_pb';
import { Event_RevealCardsSchema } from 'generated/proto/event_reveal_cards_pb';
import { Event_ShuffleSchema } from 'generated/proto/event_shuffle_pb';
import { Event_RollDieSchema } from 'generated/proto/event_roll_die_pb';
import { Event_DumpZoneSchema } from 'generated/proto/event_dump_zone_pb';
import { Event_ChangeZonePropertiesSchema } from 'generated/proto/event_change_zone_properties_pb';
describe('Actions', () => { describe('Actions', () => {
it('clearStore', () => { it('clearStore', () => {
@ -34,8 +15,8 @@ describe('Actions', () => {
}); });
it('gameJoined', () => { it('gameJoined', () => {
const entry = makeGameEntry(); const data = create(Data.Event_GameJoinedSchema, { hostId: 1, playerId: 2 });
expect(Actions.gameJoined(1, entry)).toEqual({ type: Types.GAME_JOINED, gameId: 1, gameEntry: entry }); expect(Actions.gameJoined(data)).toEqual({ type: Types.GAME_JOINED, data });
}); });
it('gameLeft', () => { it('gameLeft', () => {
@ -51,7 +32,7 @@ describe('Actions', () => {
}); });
it('gameStateChanged', () => { it('gameStateChanged', () => {
const data = create(Event_GameStateChangedSchema, { const data = create(Data.Event_GameStateChangedSchema, {
playerList: [], gameStarted: true, activePlayerId: 1, activePhase: 0, secondsElapsed: 0 playerList: [], gameStarted: true, activePlayerId: 1, activePhase: 0, secondsElapsed: 0
}); });
expect(Actions.gameStateChanged(1, data)).toEqual({ type: Types.GAME_STATE_CHANGED, gameId: 1, data }); expect(Actions.gameStateChanged(1, data)).toEqual({ type: Types.GAME_STATE_CHANGED, gameId: 1, data });
@ -81,85 +62,85 @@ describe('Actions', () => {
}); });
it('cardMoved', () => { it('cardMoved', () => {
const data = create(Event_MoveCardSchema, { cardId: 1 }); const data = create(Data.Event_MoveCardSchema, { cardId: 1 });
expect(Actions.cardMoved(1, 2, data)).toEqual({ type: Types.CARD_MOVED, gameId: 1, playerId: 2, data }); expect(Actions.cardMoved(1, 2, data)).toEqual({ type: Types.CARD_MOVED, gameId: 1, playerId: 2, data });
}); });
it('cardFlipped', () => { it('cardFlipped', () => {
const data = create(Event_FlipCardSchema, { cardId: 1 }); const data = create(Data.Event_FlipCardSchema, { cardId: 1 });
expect(Actions.cardFlipped(1, 2, data)).toEqual({ type: Types.CARD_FLIPPED, gameId: 1, playerId: 2, data }); expect(Actions.cardFlipped(1, 2, data)).toEqual({ type: Types.CARD_FLIPPED, gameId: 1, playerId: 2, data });
}); });
it('cardDestroyed', () => { it('cardDestroyed', () => {
const data = create(Event_DestroyCardSchema, { cardId: 1 }); const data = create(Data.Event_DestroyCardSchema, { cardId: 1 });
expect(Actions.cardDestroyed(1, 2, data)).toEqual({ type: Types.CARD_DESTROYED, gameId: 1, playerId: 2, data }); expect(Actions.cardDestroyed(1, 2, data)).toEqual({ type: Types.CARD_DESTROYED, gameId: 1, playerId: 2, data });
}); });
it('cardAttached', () => { it('cardAttached', () => {
const data = create(Event_AttachCardSchema, { cardId: 1 }); const data = create(Data.Event_AttachCardSchema, { cardId: 1 });
expect(Actions.cardAttached(1, 2, data)).toEqual({ type: Types.CARD_ATTACHED, gameId: 1, playerId: 2, data }); expect(Actions.cardAttached(1, 2, data)).toEqual({ type: Types.CARD_ATTACHED, gameId: 1, playerId: 2, data });
}); });
it('tokenCreated', () => { it('tokenCreated', () => {
const data = create(Event_CreateTokenSchema, { cardId: 1 }); const data = create(Data.Event_CreateTokenSchema, { cardId: 1 });
expect(Actions.tokenCreated(1, 2, data)).toEqual({ type: Types.TOKEN_CREATED, gameId: 1, playerId: 2, data }); expect(Actions.tokenCreated(1, 2, data)).toEqual({ type: Types.TOKEN_CREATED, gameId: 1, playerId: 2, data });
}); });
it('cardAttrChanged', () => { it('cardAttrChanged', () => {
const data = create(Event_SetCardAttrSchema, { cardId: 1 }); const data = create(Data.Event_SetCardAttrSchema, { cardId: 1 });
expect(Actions.cardAttrChanged(1, 2, data)).toEqual({ type: Types.CARD_ATTR_CHANGED, gameId: 1, playerId: 2, data }); expect(Actions.cardAttrChanged(1, 2, data)).toEqual({ type: Types.CARD_ATTR_CHANGED, gameId: 1, playerId: 2, data });
}); });
it('cardCounterChanged', () => { it('cardCounterChanged', () => {
const data = create(Event_SetCardCounterSchema, { cardId: 1 }); const data = create(Data.Event_SetCardCounterSchema, { cardId: 1 });
expect(Actions.cardCounterChanged(1, 2, data)).toEqual({ type: Types.CARD_COUNTER_CHANGED, gameId: 1, playerId: 2, data }); expect(Actions.cardCounterChanged(1, 2, data)).toEqual({ type: Types.CARD_COUNTER_CHANGED, gameId: 1, playerId: 2, data });
}); });
it('arrowCreated', () => { it('arrowCreated', () => {
const arrow = makeArrow(); const arrow = makeArrow();
const data = create(Event_CreateArrowSchema, { arrowInfo: arrow }); const data = create(Data.Event_CreateArrowSchema, { arrowInfo: arrow });
expect(Actions.arrowCreated(1, 2, data)).toEqual({ type: Types.ARROW_CREATED, gameId: 1, playerId: 2, data }); expect(Actions.arrowCreated(1, 2, data)).toEqual({ type: Types.ARROW_CREATED, gameId: 1, playerId: 2, data });
}); });
it('arrowDeleted', () => { it('arrowDeleted', () => {
const data = create(Event_DeleteArrowSchema, { arrowId: 3 }); const data = create(Data.Event_DeleteArrowSchema, { arrowId: 3 });
expect(Actions.arrowDeleted(1, 2, data)).toEqual({ type: Types.ARROW_DELETED, gameId: 1, playerId: 2, data }); expect(Actions.arrowDeleted(1, 2, data)).toEqual({ type: Types.ARROW_DELETED, gameId: 1, playerId: 2, data });
}); });
it('counterCreated', () => { it('counterCreated', () => {
const counter = makeCounter(); const counter = makeCounter();
const data = create(Event_CreateCounterSchema, { counterInfo: counter }); const data = create(Data.Event_CreateCounterSchema, { counterInfo: counter });
expect(Actions.counterCreated(1, 2, data)).toEqual({ type: Types.COUNTER_CREATED, gameId: 1, playerId: 2, data }); expect(Actions.counterCreated(1, 2, data)).toEqual({ type: Types.COUNTER_CREATED, gameId: 1, playerId: 2, data });
}); });
it('counterSet', () => { it('counterSet', () => {
const data = create(Event_SetCounterSchema, { counterId: 1, value: 10 }); const data = create(Data.Event_SetCounterSchema, { counterId: 1, value: 10 });
expect(Actions.counterSet(1, 2, data)).toEqual({ type: Types.COUNTER_SET, gameId: 1, playerId: 2, data }); expect(Actions.counterSet(1, 2, data)).toEqual({ type: Types.COUNTER_SET, gameId: 1, playerId: 2, data });
}); });
it('counterDeleted', () => { it('counterDeleted', () => {
const data = create(Event_DelCounterSchema, { counterId: 1 }); const data = create(Data.Event_DelCounterSchema, { counterId: 1 });
expect(Actions.counterDeleted(1, 2, data)).toEqual({ type: Types.COUNTER_DELETED, gameId: 1, playerId: 2, data }); expect(Actions.counterDeleted(1, 2, data)).toEqual({ type: Types.COUNTER_DELETED, gameId: 1, playerId: 2, data });
}); });
it('cardsDrawn', () => { it('cardsDrawn', () => {
const card = makeCard(); const card = makeCard();
const data = create(Event_DrawCardsSchema, { number: 2, cards: [card] }); const data = create(Data.Event_DrawCardsSchema, { number: 2, cards: [card] });
expect(Actions.cardsDrawn(1, 2, data)).toEqual({ type: Types.CARDS_DRAWN, gameId: 1, playerId: 2, data }); expect(Actions.cardsDrawn(1, 2, data)).toEqual({ type: Types.CARDS_DRAWN, gameId: 1, playerId: 2, data });
}); });
it('cardsRevealed', () => { it('cardsRevealed', () => {
const data = create(Event_RevealCardsSchema, { zoneName: 'hand', cards: [] }); const data = create(Data.Event_RevealCardsSchema, { zoneName: 'hand', cards: [] });
expect(Actions.cardsRevealed(1, 2, data)).toEqual({ type: Types.CARDS_REVEALED, gameId: 1, playerId: 2, data }); expect(Actions.cardsRevealed(1, 2, data)).toEqual({ type: Types.CARDS_REVEALED, gameId: 1, playerId: 2, data });
}); });
it('zoneShuffled', () => { it('zoneShuffled', () => {
const data = create(Event_ShuffleSchema, { zoneName: 'deck', start: 0, end: 39 }); const data = create(Data.Event_ShuffleSchema, { zoneName: 'deck', start: 0, end: 39 });
expect(Actions.zoneShuffled(1, 2, data)).toEqual({ type: Types.ZONE_SHUFFLED, gameId: 1, playerId: 2, data }); expect(Actions.zoneShuffled(1, 2, data)).toEqual({ type: Types.ZONE_SHUFFLED, gameId: 1, playerId: 2, data });
}); });
it('dieRolled', () => { it('dieRolled', () => {
const data = create(Event_RollDieSchema, { sides: 6, value: 4, values: [4] }); const data = create(Data.Event_RollDieSchema, { sides: 6, value: 4, values: [4] });
expect(Actions.dieRolled(1, 2, data)).toEqual({ type: Types.DIE_ROLLED, gameId: 1, playerId: 2, data }); expect(Actions.dieRolled(1, 2, data)).toEqual({ type: Types.DIE_ROLLED, gameId: 1, playerId: 2, data });
}); });
@ -176,12 +157,12 @@ describe('Actions', () => {
}); });
it('zoneDumped', () => { it('zoneDumped', () => {
const data = create(Event_DumpZoneSchema, { zoneOwnerId: 1, zoneName: 'hand', numberCards: 3, isReversed: false }); const data = create(Data.Event_DumpZoneSchema, { zoneOwnerId: 1, zoneName: 'hand', numberCards: 3, isReversed: false });
expect(Actions.zoneDumped(1, 2, data)).toEqual({ type: Types.ZONE_DUMPED, gameId: 1, playerId: 2, data }); expect(Actions.zoneDumped(1, 2, data)).toEqual({ type: Types.ZONE_DUMPED, gameId: 1, playerId: 2, data });
}); });
it('zonePropertiesChanged', () => { it('zonePropertiesChanged', () => {
const data = create(Event_ChangeZonePropertiesSchema, { zoneName: 'deck', alwaysRevealTopCard: true, alwaysLookAtTopCard: false }); const data = create(Data.Event_ChangeZonePropertiesSchema, { zoneName: 'deck', alwaysRevealTopCard: true, alwaysLookAtTopCard: false });
expect(Actions.zonePropertiesChanged(1, 2, data)).toEqual({ expect(Actions.zonePropertiesChanged(1, 2, data)).toEqual({
type: Types.ZONE_PROPERTIES_CHANGED, type: Types.ZONE_PROPERTIES_CHANGED,
gameId: 1, gameId: 1,

View file

@ -1,24 +1,4 @@
import type { Event_AttachCard } from 'generated/proto/event_attach_card_pb'; import type { Data } from '@app/types';
import type { Event_ChangeZoneProperties } from 'generated/proto/event_change_zone_properties_pb';
import type { Event_CreateArrow } from 'generated/proto/event_create_arrow_pb';
import type { Event_CreateCounter } from 'generated/proto/event_create_counter_pb';
import type { Event_CreateToken } from 'generated/proto/event_create_token_pb';
import type { Event_DelCounter } from 'generated/proto/event_del_counter_pb';
import type { Event_DeleteArrow } from 'generated/proto/event_delete_arrow_pb';
import type { Event_DestroyCard } from 'generated/proto/event_destroy_card_pb';
import type { Event_DrawCards } from 'generated/proto/event_draw_cards_pb';
import type { Event_DumpZone } from 'generated/proto/event_dump_zone_pb';
import type { Event_FlipCard } from 'generated/proto/event_flip_card_pb';
import type { Event_GameStateChanged } from 'generated/proto/event_game_state_changed_pb';
import type { Event_MoveCard } from 'generated/proto/event_move_card_pb';
import type { ServerInfo_PlayerProperties } from 'generated/proto/serverinfo_playerproperties_pb';
import type { Event_RevealCards } from 'generated/proto/event_reveal_cards_pb';
import type { Event_RollDie } from 'generated/proto/event_roll_die_pb';
import type { Event_SetCardAttr } from 'generated/proto/event_set_card_attr_pb';
import type { Event_SetCardCounter } from 'generated/proto/event_set_card_counter_pb';
import type { Event_SetCounter } from 'generated/proto/event_set_counter_pb';
import type { Event_Shuffle } from 'generated/proto/event_shuffle_pb';
import { GameEntry } from './game.interfaces';
import { Types } from './game.types'; import { Types } from './game.types';
export const Actions = { export const Actions = {
@ -26,10 +6,9 @@ export const Actions = {
type: Types.CLEAR_STORE, type: Types.CLEAR_STORE,
}), }),
gameJoined: (gameId: number, gameEntry: GameEntry) => ({ gameJoined: (data: Data.Event_GameJoined) => ({
type: Types.GAME_JOINED, type: Types.GAME_JOINED,
gameId, data,
gameEntry,
}), }),
gameLeft: (gameId: number) => ({ gameLeft: (gameId: number) => ({
@ -48,13 +27,13 @@ export const Actions = {
hostId, hostId,
}), }),
gameStateChanged: (gameId: number, data: Event_GameStateChanged) => ({ gameStateChanged: (gameId: number, data: Data.Event_GameStateChanged) => ({
type: Types.GAME_STATE_CHANGED, type: Types.GAME_STATE_CHANGED,
gameId, gameId,
data, data,
}), }),
playerJoined: (gameId: number, playerProperties: ServerInfo_PlayerProperties) => ({ playerJoined: (gameId: number, playerProperties: Data.ServerInfo_PlayerProperties) => ({
type: Types.PLAYER_JOINED, type: Types.PLAYER_JOINED,
gameId, gameId,
playerProperties, playerProperties,
@ -67,7 +46,7 @@ export const Actions = {
reason, reason,
}), }),
playerPropertiesChanged: (gameId: number, playerId: number, properties: ServerInfo_PlayerProperties) => ({ playerPropertiesChanged: (gameId: number, playerId: number, properties: Data.ServerInfo_PlayerProperties) => ({
type: Types.PLAYER_PROPERTIES_CHANGED, type: Types.PLAYER_PROPERTIES_CHANGED,
gameId, gameId,
playerId, playerId,
@ -79,112 +58,112 @@ export const Actions = {
gameId, gameId,
}), }),
cardMoved: (gameId: number, playerId: number, data: Event_MoveCard) => ({ cardMoved: (gameId: number, playerId: number, data: Data.Event_MoveCard) => ({
type: Types.CARD_MOVED, type: Types.CARD_MOVED,
gameId, gameId,
playerId, playerId,
data, data,
}), }),
cardFlipped: (gameId: number, playerId: number, data: Event_FlipCard) => ({ cardFlipped: (gameId: number, playerId: number, data: Data.Event_FlipCard) => ({
type: Types.CARD_FLIPPED, type: Types.CARD_FLIPPED,
gameId, gameId,
playerId, playerId,
data, data,
}), }),
cardDestroyed: (gameId: number, playerId: number, data: Event_DestroyCard) => ({ cardDestroyed: (gameId: number, playerId: number, data: Data.Event_DestroyCard) => ({
type: Types.CARD_DESTROYED, type: Types.CARD_DESTROYED,
gameId, gameId,
playerId, playerId,
data, data,
}), }),
cardAttached: (gameId: number, playerId: number, data: Event_AttachCard) => ({ cardAttached: (gameId: number, playerId: number, data: Data.Event_AttachCard) => ({
type: Types.CARD_ATTACHED, type: Types.CARD_ATTACHED,
gameId, gameId,
playerId, playerId,
data, data,
}), }),
tokenCreated: (gameId: number, playerId: number, data: Event_CreateToken) => ({ tokenCreated: (gameId: number, playerId: number, data: Data.Event_CreateToken) => ({
type: Types.TOKEN_CREATED, type: Types.TOKEN_CREATED,
gameId, gameId,
playerId, playerId,
data, data,
}), }),
cardAttrChanged: (gameId: number, playerId: number, data: Event_SetCardAttr) => ({ cardAttrChanged: (gameId: number, playerId: number, data: Data.Event_SetCardAttr) => ({
type: Types.CARD_ATTR_CHANGED, type: Types.CARD_ATTR_CHANGED,
gameId, gameId,
playerId, playerId,
data, data,
}), }),
cardCounterChanged: (gameId: number, playerId: number, data: Event_SetCardCounter) => ({ cardCounterChanged: (gameId: number, playerId: number, data: Data.Event_SetCardCounter) => ({
type: Types.CARD_COUNTER_CHANGED, type: Types.CARD_COUNTER_CHANGED,
gameId, gameId,
playerId, playerId,
data, data,
}), }),
arrowCreated: (gameId: number, playerId: number, data: Event_CreateArrow) => ({ arrowCreated: (gameId: number, playerId: number, data: Data.Event_CreateArrow) => ({
type: Types.ARROW_CREATED, type: Types.ARROW_CREATED,
gameId, gameId,
playerId, playerId,
data, data,
}), }),
arrowDeleted: (gameId: number, playerId: number, data: Event_DeleteArrow) => ({ arrowDeleted: (gameId: number, playerId: number, data: Data.Event_DeleteArrow) => ({
type: Types.ARROW_DELETED, type: Types.ARROW_DELETED,
gameId, gameId,
playerId, playerId,
data, data,
}), }),
counterCreated: (gameId: number, playerId: number, data: Event_CreateCounter) => ({ counterCreated: (gameId: number, playerId: number, data: Data.Event_CreateCounter) => ({
type: Types.COUNTER_CREATED, type: Types.COUNTER_CREATED,
gameId, gameId,
playerId, playerId,
data, data,
}), }),
counterSet: (gameId: number, playerId: number, data: Event_SetCounter) => ({ counterSet: (gameId: number, playerId: number, data: Data.Event_SetCounter) => ({
type: Types.COUNTER_SET, type: Types.COUNTER_SET,
gameId, gameId,
playerId, playerId,
data, data,
}), }),
counterDeleted: (gameId: number, playerId: number, data: Event_DelCounter) => ({ counterDeleted: (gameId: number, playerId: number, data: Data.Event_DelCounter) => ({
type: Types.COUNTER_DELETED, type: Types.COUNTER_DELETED,
gameId, gameId,
playerId, playerId,
data, data,
}), }),
cardsDrawn: (gameId: number, playerId: number, data: Event_DrawCards) => ({ cardsDrawn: (gameId: number, playerId: number, data: Data.Event_DrawCards) => ({
type: Types.CARDS_DRAWN, type: Types.CARDS_DRAWN,
gameId, gameId,
playerId, playerId,
data, data,
}), }),
cardsRevealed: (gameId: number, playerId: number, data: Event_RevealCards) => ({ cardsRevealed: (gameId: number, playerId: number, data: Data.Event_RevealCards) => ({
type: Types.CARDS_REVEALED, type: Types.CARDS_REVEALED,
gameId, gameId,
playerId, playerId,
data, data,
}), }),
zoneShuffled: (gameId: number, playerId: number, data: Event_Shuffle) => ({ zoneShuffled: (gameId: number, playerId: number, data: Data.Event_Shuffle) => ({
type: Types.ZONE_SHUFFLED, type: Types.ZONE_SHUFFLED,
gameId, gameId,
playerId, playerId,
data, data,
}), }),
dieRolled: (gameId: number, playerId: number, data: Event_RollDie) => ({ dieRolled: (gameId: number, playerId: number, data: Data.Event_RollDie) => ({
type: Types.DIE_ROLLED, type: Types.DIE_ROLLED,
gameId, gameId,
playerId, playerId,
@ -209,14 +188,14 @@ export const Actions = {
reversed, reversed,
}), }),
zoneDumped: (gameId: number, playerId: number, data: Event_DumpZone) => ({ zoneDumped: (gameId: number, playerId: number, data: Data.Event_DumpZone) => ({
type: Types.ZONE_DUMPED, type: Types.ZONE_DUMPED,
gameId, gameId,
playerId, playerId,
data, data,
}), }),
zonePropertiesChanged: (gameId: number, playerId: number, data: Event_ChangeZoneProperties) => ({ zonePropertiesChanged: (gameId: number, playerId: number, data: Data.Event_ChangeZoneProperties) => ({
type: Types.ZONE_PROPERTIES_CHANGED, type: Types.ZONE_PROPERTIES_CHANGED,
gameId, gameId,
playerId, playerId,

View file

@ -1,37 +1,16 @@
vi.mock('store/store', () => ({ store: { dispatch: vi.fn() } })); vi.mock('../store', () => ({ store: { dispatch: vi.fn() } }));
import { create } from '@bufbuild/protobuf'; import { create } from '@bufbuild/protobuf';
import { store } from 'store/store'; import { Data } from '@app/types';
import { store } from '..';
import { Actions } from './game.actions'; import { Actions } from './game.actions';
import { Dispatch } from './game.dispatch'; import { Dispatch } from './game.dispatch';
import { import {
makeArrow, makeArrow,
makeCard, makeCard,
makeCounter, makeCounter,
makeGameEntry,
makePlayerProperties, makePlayerProperties,
} from './__mocks__/fixtures'; } from './__mocks__/fixtures';
import { Event_GameStateChangedSchema } from 'generated/proto/event_game_state_changed_pb';
import { Event_MoveCardSchema } from 'generated/proto/event_move_card_pb';
import { Event_FlipCardSchema } from 'generated/proto/event_flip_card_pb';
import { Event_DestroyCardSchema } from 'generated/proto/event_destroy_card_pb';
import { Event_AttachCardSchema } from 'generated/proto/event_attach_card_pb';
import { Event_CreateTokenSchema } from 'generated/proto/event_create_token_pb';
import { Event_SetCardAttrSchema } from 'generated/proto/event_set_card_attr_pb';
import { Event_SetCardCounterSchema } from 'generated/proto/event_set_card_counter_pb';
import { Event_CreateArrowSchema } from 'generated/proto/event_create_arrow_pb';
import { Event_DeleteArrowSchema } from 'generated/proto/event_delete_arrow_pb';
import { Event_CreateCounterSchema } from 'generated/proto/event_create_counter_pb';
import { Event_SetCounterSchema } from 'generated/proto/event_set_counter_pb';
import { Event_DelCounterSchema } from 'generated/proto/event_del_counter_pb';
import { Event_DrawCardsSchema } from 'generated/proto/event_draw_cards_pb';
import { Event_RevealCardsSchema } from 'generated/proto/event_reveal_cards_pb';
import { Event_ShuffleSchema } from 'generated/proto/event_shuffle_pb';
import { Event_RollDieSchema } from 'generated/proto/event_roll_die_pb';
import { Event_DumpZoneSchema } from 'generated/proto/event_dump_zone_pb';
import { Event_ChangeZonePropertiesSchema } from 'generated/proto/event_change_zone_properties_pb';
beforeEach(() => vi.clearAllMocks());
describe('Dispatch', () => { describe('Dispatch', () => {
it('clearStore dispatches Actions.clearStore()', () => { it('clearStore dispatches Actions.clearStore()', () => {
@ -40,9 +19,9 @@ describe('Dispatch', () => {
}); });
it('gameJoined dispatches Actions.gameJoined()', () => { it('gameJoined dispatches Actions.gameJoined()', () => {
const entry = makeGameEntry(); const data = create(Data.Event_GameJoinedSchema, { hostId: 1, playerId: 2 });
Dispatch.gameJoined(1, entry); Dispatch.gameJoined(data);
expect(store.dispatch).toHaveBeenCalledWith(Actions.gameJoined(1, entry)); expect(store.dispatch).toHaveBeenCalledWith(Actions.gameJoined(data));
}); });
it('gameLeft dispatches Actions.gameLeft()', () => { it('gameLeft dispatches Actions.gameLeft()', () => {
@ -61,7 +40,7 @@ describe('Dispatch', () => {
}); });
it('gameStateChanged dispatches Actions.gameStateChanged()', () => { it('gameStateChanged dispatches Actions.gameStateChanged()', () => {
const data = create(Event_GameStateChangedSchema, { const data = create(Data.Event_GameStateChangedSchema, {
playerList: [], gameStarted: false, activePlayerId: 0, activePhase: 0, secondsElapsed: 0 playerList: [], gameStarted: false, activePlayerId: 0, activePhase: 0, secondsElapsed: 0
}); });
Dispatch.gameStateChanged(1, data); Dispatch.gameStateChanged(1, data);
@ -91,97 +70,97 @@ describe('Dispatch', () => {
}); });
it('cardMoved dispatches Actions.cardMoved()', () => { it('cardMoved dispatches Actions.cardMoved()', () => {
const data = create(Event_MoveCardSchema, { cardId: 1 }); const data = create(Data.Event_MoveCardSchema, { cardId: 1 });
Dispatch.cardMoved(1, 2, data); Dispatch.cardMoved(1, 2, data);
expect(store.dispatch).toHaveBeenCalledWith(Actions.cardMoved(1, 2, data)); expect(store.dispatch).toHaveBeenCalledWith(Actions.cardMoved(1, 2, data));
}); });
it('cardFlipped dispatches Actions.cardFlipped()', () => { it('cardFlipped dispatches Actions.cardFlipped()', () => {
const data = create(Event_FlipCardSchema, { cardId: 1 }); const data = create(Data.Event_FlipCardSchema, { cardId: 1 });
Dispatch.cardFlipped(1, 2, data); Dispatch.cardFlipped(1, 2, data);
expect(store.dispatch).toHaveBeenCalledWith(Actions.cardFlipped(1, 2, data)); expect(store.dispatch).toHaveBeenCalledWith(Actions.cardFlipped(1, 2, data));
}); });
it('cardDestroyed dispatches Actions.cardDestroyed()', () => { it('cardDestroyed dispatches Actions.cardDestroyed()', () => {
const data = create(Event_DestroyCardSchema, { cardId: 1 }); const data = create(Data.Event_DestroyCardSchema, { cardId: 1 });
Dispatch.cardDestroyed(1, 2, data); Dispatch.cardDestroyed(1, 2, data);
expect(store.dispatch).toHaveBeenCalledWith(Actions.cardDestroyed(1, 2, data)); expect(store.dispatch).toHaveBeenCalledWith(Actions.cardDestroyed(1, 2, data));
}); });
it('cardAttached dispatches Actions.cardAttached()', () => { it('cardAttached dispatches Actions.cardAttached()', () => {
const data = create(Event_AttachCardSchema, { cardId: 1 }); const data = create(Data.Event_AttachCardSchema, { cardId: 1 });
Dispatch.cardAttached(1, 2, data); Dispatch.cardAttached(1, 2, data);
expect(store.dispatch).toHaveBeenCalledWith(Actions.cardAttached(1, 2, data)); expect(store.dispatch).toHaveBeenCalledWith(Actions.cardAttached(1, 2, data));
}); });
it('tokenCreated dispatches Actions.tokenCreated()', () => { it('tokenCreated dispatches Actions.tokenCreated()', () => {
const data = create(Event_CreateTokenSchema, { cardId: 1 }); const data = create(Data.Event_CreateTokenSchema, { cardId: 1 });
Dispatch.tokenCreated(1, 2, data); Dispatch.tokenCreated(1, 2, data);
expect(store.dispatch).toHaveBeenCalledWith(Actions.tokenCreated(1, 2, data)); expect(store.dispatch).toHaveBeenCalledWith(Actions.tokenCreated(1, 2, data));
}); });
it('cardAttrChanged dispatches Actions.cardAttrChanged()', () => { it('cardAttrChanged dispatches Actions.cardAttrChanged()', () => {
const data = create(Event_SetCardAttrSchema, { cardId: 1 }); const data = create(Data.Event_SetCardAttrSchema, { cardId: 1 });
Dispatch.cardAttrChanged(1, 2, data); Dispatch.cardAttrChanged(1, 2, data);
expect(store.dispatch).toHaveBeenCalledWith(Actions.cardAttrChanged(1, 2, data)); expect(store.dispatch).toHaveBeenCalledWith(Actions.cardAttrChanged(1, 2, data));
}); });
it('cardCounterChanged dispatches Actions.cardCounterChanged()', () => { it('cardCounterChanged dispatches Actions.cardCounterChanged()', () => {
const data = create(Event_SetCardCounterSchema, { cardId: 1 }); const data = create(Data.Event_SetCardCounterSchema, { cardId: 1 });
Dispatch.cardCounterChanged(1, 2, data); Dispatch.cardCounterChanged(1, 2, data);
expect(store.dispatch).toHaveBeenCalledWith(Actions.cardCounterChanged(1, 2, data)); expect(store.dispatch).toHaveBeenCalledWith(Actions.cardCounterChanged(1, 2, data));
}); });
it('arrowCreated dispatches Actions.arrowCreated()', () => { it('arrowCreated dispatches Actions.arrowCreated()', () => {
const data = create(Event_CreateArrowSchema, { arrowInfo: makeArrow() }); const data = create(Data.Event_CreateArrowSchema, { arrowInfo: makeArrow() });
Dispatch.arrowCreated(1, 2, data); Dispatch.arrowCreated(1, 2, data);
expect(store.dispatch).toHaveBeenCalledWith(Actions.arrowCreated(1, 2, data)); expect(store.dispatch).toHaveBeenCalledWith(Actions.arrowCreated(1, 2, data));
}); });
it('arrowDeleted dispatches Actions.arrowDeleted()', () => { it('arrowDeleted dispatches Actions.arrowDeleted()', () => {
const data = create(Event_DeleteArrowSchema, { arrowId: 3 }); const data = create(Data.Event_DeleteArrowSchema, { arrowId: 3 });
Dispatch.arrowDeleted(1, 2, data); Dispatch.arrowDeleted(1, 2, data);
expect(store.dispatch).toHaveBeenCalledWith(Actions.arrowDeleted(1, 2, data)); expect(store.dispatch).toHaveBeenCalledWith(Actions.arrowDeleted(1, 2, data));
}); });
it('counterCreated dispatches Actions.counterCreated()', () => { it('counterCreated dispatches Actions.counterCreated()', () => {
const data = create(Event_CreateCounterSchema, { counterInfo: makeCounter() }); const data = create(Data.Event_CreateCounterSchema, { counterInfo: makeCounter() });
Dispatch.counterCreated(1, 2, data); Dispatch.counterCreated(1, 2, data);
expect(store.dispatch).toHaveBeenCalledWith(Actions.counterCreated(1, 2, data)); expect(store.dispatch).toHaveBeenCalledWith(Actions.counterCreated(1, 2, data));
}); });
it('counterSet dispatches Actions.counterSet()', () => { it('counterSet dispatches Actions.counterSet()', () => {
const data = create(Event_SetCounterSchema, { counterId: 1, value: 10 }); const data = create(Data.Event_SetCounterSchema, { counterId: 1, value: 10 });
Dispatch.counterSet(1, 2, data); Dispatch.counterSet(1, 2, data);
expect(store.dispatch).toHaveBeenCalledWith(Actions.counterSet(1, 2, data)); expect(store.dispatch).toHaveBeenCalledWith(Actions.counterSet(1, 2, data));
}); });
it('counterDeleted dispatches Actions.counterDeleted()', () => { it('counterDeleted dispatches Actions.counterDeleted()', () => {
const data = create(Event_DelCounterSchema, { counterId: 1 }); const data = create(Data.Event_DelCounterSchema, { counterId: 1 });
Dispatch.counterDeleted(1, 2, data); Dispatch.counterDeleted(1, 2, data);
expect(store.dispatch).toHaveBeenCalledWith(Actions.counterDeleted(1, 2, data)); expect(store.dispatch).toHaveBeenCalledWith(Actions.counterDeleted(1, 2, data));
}); });
it('cardsDrawn dispatches Actions.cardsDrawn()', () => { it('cardsDrawn dispatches Actions.cardsDrawn()', () => {
const data = create(Event_DrawCardsSchema, { number: 2, cards: [makeCard()] }); const data = create(Data.Event_DrawCardsSchema, { number: 2, cards: [makeCard()] });
Dispatch.cardsDrawn(1, 2, data); Dispatch.cardsDrawn(1, 2, data);
expect(store.dispatch).toHaveBeenCalledWith(Actions.cardsDrawn(1, 2, data)); expect(store.dispatch).toHaveBeenCalledWith(Actions.cardsDrawn(1, 2, data));
}); });
it('cardsRevealed dispatches Actions.cardsRevealed()', () => { it('cardsRevealed dispatches Actions.cardsRevealed()', () => {
const data = create(Event_RevealCardsSchema, { zoneName: 'hand', cards: [] }); const data = create(Data.Event_RevealCardsSchema, { zoneName: 'hand', cards: [] });
Dispatch.cardsRevealed(1, 2, data); Dispatch.cardsRevealed(1, 2, data);
expect(store.dispatch).toHaveBeenCalledWith(Actions.cardsRevealed(1, 2, data)); expect(store.dispatch).toHaveBeenCalledWith(Actions.cardsRevealed(1, 2, data));
}); });
it('zoneShuffled dispatches Actions.zoneShuffled()', () => { it('zoneShuffled dispatches Actions.zoneShuffled()', () => {
const data = create(Event_ShuffleSchema, { zoneName: 'deck', start: 0, end: 39 }); const data = create(Data.Event_ShuffleSchema, { zoneName: 'deck', start: 0, end: 39 });
Dispatch.zoneShuffled(1, 2, data); Dispatch.zoneShuffled(1, 2, data);
expect(store.dispatch).toHaveBeenCalledWith(Actions.zoneShuffled(1, 2, data)); expect(store.dispatch).toHaveBeenCalledWith(Actions.zoneShuffled(1, 2, data));
}); });
it('dieRolled dispatches Actions.dieRolled()', () => { it('dieRolled dispatches Actions.dieRolled()', () => {
const data = create(Event_RollDieSchema, { sides: 6, value: 4, values: [4] }); const data = create(Data.Event_RollDieSchema, { sides: 6, value: 4, values: [4] });
Dispatch.dieRolled(1, 2, data); Dispatch.dieRolled(1, 2, data);
expect(store.dispatch).toHaveBeenCalledWith(Actions.dieRolled(1, 2, data)); expect(store.dispatch).toHaveBeenCalledWith(Actions.dieRolled(1, 2, data));
}); });
@ -202,13 +181,13 @@ describe('Dispatch', () => {
}); });
it('zoneDumped dispatches Actions.zoneDumped()', () => { it('zoneDumped dispatches Actions.zoneDumped()', () => {
const data = create(Event_DumpZoneSchema, { zoneOwnerId: 1, zoneName: 'hand', numberCards: 3, isReversed: false }); const data = create(Data.Event_DumpZoneSchema, { zoneOwnerId: 1, zoneName: 'hand', numberCards: 3, isReversed: false });
Dispatch.zoneDumped(1, 2, data); Dispatch.zoneDumped(1, 2, data);
expect(store.dispatch).toHaveBeenCalledWith(Actions.zoneDumped(1, 2, data)); expect(store.dispatch).toHaveBeenCalledWith(Actions.zoneDumped(1, 2, data));
}); });
it('zonePropertiesChanged dispatches Actions.zonePropertiesChanged()', () => { it('zonePropertiesChanged dispatches Actions.zonePropertiesChanged()', () => {
const data = create(Event_ChangeZonePropertiesSchema, { zoneName: 'deck', alwaysRevealTopCard: true, alwaysLookAtTopCard: false }); const data = create(Data.Event_ChangeZonePropertiesSchema, { zoneName: 'deck', alwaysRevealTopCard: true, alwaysLookAtTopCard: false });
Dispatch.zonePropertiesChanged(1, 2, data); Dispatch.zonePropertiesChanged(1, 2, data);
expect(store.dispatch).toHaveBeenCalledWith(Actions.zonePropertiesChanged(1, 2, data)); expect(store.dispatch).toHaveBeenCalledWith(Actions.zonePropertiesChanged(1, 2, data));
}); });

View file

@ -1,34 +1,14 @@
import type { Event_AttachCard } from 'generated/proto/event_attach_card_pb'; import type { Data } from '@app/types';
import type { Event_ChangeZoneProperties } from 'generated/proto/event_change_zone_properties_pb'; import { store } from '..';
import type { Event_CreateArrow } from 'generated/proto/event_create_arrow_pb';
import type { Event_CreateCounter } from 'generated/proto/event_create_counter_pb';
import type { Event_CreateToken } from 'generated/proto/event_create_token_pb';
import type { Event_DelCounter } from 'generated/proto/event_del_counter_pb';
import type { Event_DeleteArrow } from 'generated/proto/event_delete_arrow_pb';
import type { Event_DestroyCard } from 'generated/proto/event_destroy_card_pb';
import type { Event_DrawCards } from 'generated/proto/event_draw_cards_pb';
import type { Event_DumpZone } from 'generated/proto/event_dump_zone_pb';
import type { Event_FlipCard } from 'generated/proto/event_flip_card_pb';
import type { Event_GameStateChanged } from 'generated/proto/event_game_state_changed_pb';
import type { Event_MoveCard } from 'generated/proto/event_move_card_pb';
import type { ServerInfo_PlayerProperties } from 'generated/proto/serverinfo_playerproperties_pb';
import type { Event_RevealCards } from 'generated/proto/event_reveal_cards_pb';
import type { Event_RollDie } from 'generated/proto/event_roll_die_pb';
import type { Event_SetCardAttr } from 'generated/proto/event_set_card_attr_pb';
import type { Event_SetCardCounter } from 'generated/proto/event_set_card_counter_pb';
import type { Event_SetCounter } from 'generated/proto/event_set_counter_pb';
import type { Event_Shuffle } from 'generated/proto/event_shuffle_pb';
import { store } from 'store/store';
import { Actions } from './game.actions'; import { Actions } from './game.actions';
import { GameEntry } from './game.interfaces';
export const Dispatch = { export const Dispatch = {
clearStore: () => { clearStore: () => {
store.dispatch(Actions.clearStore()); store.dispatch(Actions.clearStore());
}, },
gameJoined: (gameId: number, gameEntry: GameEntry) => { gameJoined: (data: Data.Event_GameJoined) => {
store.dispatch(Actions.gameJoined(gameId, gameEntry)); store.dispatch(Actions.gameJoined(data));
}, },
gameLeft: (gameId: number) => { gameLeft: (gameId: number) => {
@ -43,11 +23,11 @@ export const Dispatch = {
store.dispatch(Actions.gameHostChanged(gameId, hostId)); store.dispatch(Actions.gameHostChanged(gameId, hostId));
}, },
gameStateChanged: (gameId: number, data: Event_GameStateChanged) => { gameStateChanged: (gameId: number, data: Data.Event_GameStateChanged) => {
store.dispatch(Actions.gameStateChanged(gameId, data)); store.dispatch(Actions.gameStateChanged(gameId, data));
}, },
playerJoined: (gameId: number, playerProperties: ServerInfo_PlayerProperties) => { playerJoined: (gameId: number, playerProperties: Data.ServerInfo_PlayerProperties) => {
store.dispatch(Actions.playerJoined(gameId, playerProperties)); store.dispatch(Actions.playerJoined(gameId, playerProperties));
}, },
@ -55,7 +35,7 @@ export const Dispatch = {
store.dispatch(Actions.playerLeft(gameId, playerId, reason)); store.dispatch(Actions.playerLeft(gameId, playerId, reason));
}, },
playerPropertiesChanged: (gameId: number, playerId: number, properties: ServerInfo_PlayerProperties) => { playerPropertiesChanged: (gameId: number, playerId: number, properties: Data.ServerInfo_PlayerProperties) => {
store.dispatch(Actions.playerPropertiesChanged(gameId, playerId, properties)); store.dispatch(Actions.playerPropertiesChanged(gameId, playerId, properties));
}, },
@ -63,67 +43,67 @@ export const Dispatch = {
store.dispatch(Actions.kicked(gameId)); store.dispatch(Actions.kicked(gameId));
}, },
cardMoved: (gameId: number, playerId: number, data: Event_MoveCard) => { cardMoved: (gameId: number, playerId: number, data: Data.Event_MoveCard) => {
store.dispatch(Actions.cardMoved(gameId, playerId, data)); store.dispatch(Actions.cardMoved(gameId, playerId, data));
}, },
cardFlipped: (gameId: number, playerId: number, data: Event_FlipCard) => { cardFlipped: (gameId: number, playerId: number, data: Data.Event_FlipCard) => {
store.dispatch(Actions.cardFlipped(gameId, playerId, data)); store.dispatch(Actions.cardFlipped(gameId, playerId, data));
}, },
cardDestroyed: (gameId: number, playerId: number, data: Event_DestroyCard) => { cardDestroyed: (gameId: number, playerId: number, data: Data.Event_DestroyCard) => {
store.dispatch(Actions.cardDestroyed(gameId, playerId, data)); store.dispatch(Actions.cardDestroyed(gameId, playerId, data));
}, },
cardAttached: (gameId: number, playerId: number, data: Event_AttachCard) => { cardAttached: (gameId: number, playerId: number, data: Data.Event_AttachCard) => {
store.dispatch(Actions.cardAttached(gameId, playerId, data)); store.dispatch(Actions.cardAttached(gameId, playerId, data));
}, },
tokenCreated: (gameId: number, playerId: number, data: Event_CreateToken) => { tokenCreated: (gameId: number, playerId: number, data: Data.Event_CreateToken) => {
store.dispatch(Actions.tokenCreated(gameId, playerId, data)); store.dispatch(Actions.tokenCreated(gameId, playerId, data));
}, },
cardAttrChanged: (gameId: number, playerId: number, data: Event_SetCardAttr) => { cardAttrChanged: (gameId: number, playerId: number, data: Data.Event_SetCardAttr) => {
store.dispatch(Actions.cardAttrChanged(gameId, playerId, data)); store.dispatch(Actions.cardAttrChanged(gameId, playerId, data));
}, },
cardCounterChanged: (gameId: number, playerId: number, data: Event_SetCardCounter) => { cardCounterChanged: (gameId: number, playerId: number, data: Data.Event_SetCardCounter) => {
store.dispatch(Actions.cardCounterChanged(gameId, playerId, data)); store.dispatch(Actions.cardCounterChanged(gameId, playerId, data));
}, },
arrowCreated: (gameId: number, playerId: number, data: Event_CreateArrow) => { arrowCreated: (gameId: number, playerId: number, data: Data.Event_CreateArrow) => {
store.dispatch(Actions.arrowCreated(gameId, playerId, data)); store.dispatch(Actions.arrowCreated(gameId, playerId, data));
}, },
arrowDeleted: (gameId: number, playerId: number, data: Event_DeleteArrow) => { arrowDeleted: (gameId: number, playerId: number, data: Data.Event_DeleteArrow) => {
store.dispatch(Actions.arrowDeleted(gameId, playerId, data)); store.dispatch(Actions.arrowDeleted(gameId, playerId, data));
}, },
counterCreated: (gameId: number, playerId: number, data: Event_CreateCounter) => { counterCreated: (gameId: number, playerId: number, data: Data.Event_CreateCounter) => {
store.dispatch(Actions.counterCreated(gameId, playerId, data)); store.dispatch(Actions.counterCreated(gameId, playerId, data));
}, },
counterSet: (gameId: number, playerId: number, data: Event_SetCounter) => { counterSet: (gameId: number, playerId: number, data: Data.Event_SetCounter) => {
store.dispatch(Actions.counterSet(gameId, playerId, data)); store.dispatch(Actions.counterSet(gameId, playerId, data));
}, },
counterDeleted: (gameId: number, playerId: number, data: Event_DelCounter) => { counterDeleted: (gameId: number, playerId: number, data: Data.Event_DelCounter) => {
store.dispatch(Actions.counterDeleted(gameId, playerId, data)); store.dispatch(Actions.counterDeleted(gameId, playerId, data));
}, },
cardsDrawn: (gameId: number, playerId: number, data: Event_DrawCards) => { cardsDrawn: (gameId: number, playerId: number, data: Data.Event_DrawCards) => {
store.dispatch(Actions.cardsDrawn(gameId, playerId, data)); store.dispatch(Actions.cardsDrawn(gameId, playerId, data));
}, },
cardsRevealed: (gameId: number, playerId: number, data: Event_RevealCards) => { cardsRevealed: (gameId: number, playerId: number, data: Data.Event_RevealCards) => {
store.dispatch(Actions.cardsRevealed(gameId, playerId, data)); store.dispatch(Actions.cardsRevealed(gameId, playerId, data));
}, },
zoneShuffled: (gameId: number, playerId: number, data: Event_Shuffle) => { zoneShuffled: (gameId: number, playerId: number, data: Data.Event_Shuffle) => {
store.dispatch(Actions.zoneShuffled(gameId, playerId, data)); store.dispatch(Actions.zoneShuffled(gameId, playerId, data));
}, },
dieRolled: (gameId: number, playerId: number, data: Event_RollDie) => { dieRolled: (gameId: number, playerId: number, data: Data.Event_RollDie) => {
store.dispatch(Actions.dieRolled(gameId, playerId, data)); store.dispatch(Actions.dieRolled(gameId, playerId, data));
}, },
@ -139,11 +119,11 @@ export const Dispatch = {
store.dispatch(Actions.turnReversed(gameId, reversed)); store.dispatch(Actions.turnReversed(gameId, reversed));
}, },
zoneDumped: (gameId: number, playerId: number, data: Event_DumpZone) => { zoneDumped: (gameId: number, playerId: number, data: Data.Event_DumpZone) => {
store.dispatch(Actions.zoneDumped(gameId, playerId, data)); store.dispatch(Actions.zoneDumped(gameId, playerId, data));
}, },
zonePropertiesChanged: (gameId: number, playerId: number, data: Event_ChangeZoneProperties) => { zonePropertiesChanged: (gameId: number, playerId: number, data: Data.Event_ChangeZoneProperties) => {
store.dispatch(Actions.zonePropertiesChanged(gameId, playerId, data)); store.dispatch(Actions.zonePropertiesChanged(gameId, playerId, data));
}, },

View file

@ -1,7 +1,4 @@
import type { ServerInfo_Card } from 'generated/proto/serverinfo_card_pb'; import type { Data } from '@app/types';
import type { ServerInfo_Counter } from 'generated/proto/serverinfo_counter_pb';
import type { ServerInfo_Arrow } from 'generated/proto/serverinfo_arrow_pb';
import type { ServerInfo_PlayerProperties } from 'generated/proto/serverinfo_playerproperties_pb';
export interface GamesState { export interface GamesState {
games: { [gameId: number]: GameEntry }; games: { [gameId: number]: GameEntry };
@ -32,14 +29,14 @@ export interface GameEntry {
/** Normalized from ServerInfo_Player — keyed collections for O(1) lookup. */ /** Normalized from ServerInfo_Player — keyed collections for O(1) lookup. */
export interface PlayerEntry { export interface PlayerEntry {
properties: ServerInfo_PlayerProperties; properties: Data.ServerInfo_PlayerProperties;
deckList: string; deckList: string;
/** Zones keyed by zone name (e.g. "hand", "deck", "table"). */ /** Zones keyed by zone name (e.g. "hand", "deck", "table"). */
zones: { [zoneName: string]: ZoneEntry }; zones: { [zoneName: string]: ZoneEntry };
/** Player-level counters (e.g. life) keyed by counter id. */ /** Player-level counters (e.g. life) keyed by counter id. */
counters: { [counterId: number]: ServerInfo_Counter }; counters: { [counterId: number]: Data.ServerInfo_Counter };
/** Arrows keyed by arrow id. */ /** Arrows keyed by arrow id. */
arrows: { [arrowId: number]: ServerInfo_Arrow }; arrows: { [arrowId: number]: Data.ServerInfo_Arrow };
} }
/** Normalized from ServerInfo_Zone — card list is an ordered array matching proto. */ /** Normalized from ServerInfo_Zone — card list is an ordered array matching proto. */
@ -51,7 +48,7 @@ export interface ZoneEntry {
/** Authoritative card count (used for hidden zones where cardList may be empty). */ /** Authoritative card count (used for hidden zones where cardList may be empty). */
cardCount: number; cardCount: number;
/** Ordered card list; may be empty for hidden zones with no dump active. */ /** Ordered card list; may be empty for hidden zones with no dump active. */
cards: ServerInfo_Card[]; cards: Data.ServerInfo_Card[];
alwaysRevealTopCard: boolean; alwaysRevealTopCard: boolean;
alwaysLookAtTopCard: boolean; alwaysLookAtTopCard: boolean;
} }

View file

@ -1,6 +1,5 @@
import { create } from '@bufbuild/protobuf'; import { create } from '@bufbuild/protobuf';
import { CardAttribute } from 'generated/proto/card_attributes_pb'; import { Data } from '@app/types';
import type { ServerInfo_Player } from 'generated/proto/serverinfo_player_pb';
import { gamesReducer } from './game.reducer'; import { gamesReducer } from './game.reducer';
import { Types } from './game.types'; import { Types } from './game.types';
import { import {
@ -13,7 +12,6 @@ import {
makeState, makeState,
makeZoneEntry, makeZoneEntry,
} from './__mocks__/fixtures'; } from './__mocks__/fixtures';
import { ServerInfo_PlayerSchema } from 'generated/proto/serverinfo_player_pb';
// ── 2A: Initialisation & lifecycle ─────────────────────────────────────────── // ── 2A: Initialisation & lifecycle ───────────────────────────────────────────
@ -30,9 +28,16 @@ describe('2A: Initialisation & lifecycle', () => {
}); });
it('GAME_JOINED → inserts gameEntry keyed by gameId', () => { it('GAME_JOINED → inserts gameEntry keyed by gameId', () => {
const entry = makeGameEntry({ gameId: 42 }); const data = create(Data.Event_GameJoinedSchema, {
const result = gamesReducer({ games: {} }, { type: Types.GAME_JOINED, gameId: 42, gameEntry: entry }); gameInfo: create(Data.ServerInfo_GameSchema, { gameId: 42, roomId: 1, description: 'test' }),
expect(result.games[42]).toBe(entry); hostId: 5,
playerId: 2,
spectator: false,
judge: false,
resuming: false,
});
const result = gamesReducer({ games: {} }, { type: Types.GAME_JOINED, data });
expect(result.games[42]).toEqual(expect.objectContaining({ gameId: 42, hostId: 5, localPlayerId: 2 }));
}); });
it('GAME_LEFT → removes game by gameId', () => { it('GAME_LEFT → removes game by gameId', () => {
@ -69,8 +74,8 @@ describe('2B: Game state & player management', () => {
const card = makeCard({ id: 5 }); const card = makeCard({ id: 5 });
const counter = makeCounter({ id: 2 }); const counter = makeCounter({ id: 2 });
const arrow = makeArrow({ id: 3 }); const arrow = makeArrow({ id: 3 });
const playerList: ServerInfo_Player[] = [ const playerList: Data.ServerInfo_Player[] = [
create(ServerInfo_PlayerSchema, { create(Data.ServerInfo_PlayerSchema, {
properties: makePlayerProperties({ playerId: 7 }), properties: makePlayerProperties({ playerId: 7 }),
deckList: 'some deck', deckList: 'some deck',
zoneList: [ zoneList: [
@ -550,7 +555,7 @@ describe('2E: CARD_ATTR_CHANGED', () => {
}); });
} }
function dispatchAttr(state: ReturnType<typeof makeState>, attribute: CardAttribute, attrValue: string) { function dispatchAttr(state: ReturnType<typeof makeState>, attribute: Data.CardAttribute, attrValue: string) {
return gamesReducer(state, { return gamesReducer(state, {
type: Types.CARD_ATTR_CHANGED, type: Types.CARD_ATTR_CHANGED,
gameId: 1, gameId: 1,
@ -560,37 +565,37 @@ describe('2E: CARD_ATTR_CHANGED', () => {
} }
it('AttrTapped (1) → card.tapped = true when attrValue is "1"', () => { it('AttrTapped (1) → card.tapped = true when attrValue is "1"', () => {
const result = dispatchAttr(stateWithCard(), CardAttribute.AttrTapped, '1'); const result = dispatchAttr(stateWithCard(), Data.CardAttribute.AttrTapped, '1');
expect(result.games[1].players[1].zones['table'].cards[0].tapped).toBe(true); expect(result.games[1].players[1].zones['table'].cards[0].tapped).toBe(true);
}); });
it('AttrAttacking (2) → card.attacking = true when attrValue is "1"', () => { it('AttrAttacking (2) → card.attacking = true when attrValue is "1"', () => {
const result = dispatchAttr(stateWithCard(), CardAttribute.AttrAttacking, '1'); const result = dispatchAttr(stateWithCard(), Data.CardAttribute.AttrAttacking, '1');
expect(result.games[1].players[1].zones['table'].cards[0].attacking).toBe(true); expect(result.games[1].players[1].zones['table'].cards[0].attacking).toBe(true);
}); });
it('AttrFaceDown (3) → card.faceDown = true when attrValue is "1"', () => { it('AttrFaceDown (3) → card.faceDown = true when attrValue is "1"', () => {
const result = dispatchAttr(stateWithCard(), CardAttribute.AttrFaceDown, '1'); const result = dispatchAttr(stateWithCard(), Data.CardAttribute.AttrFaceDown, '1');
expect(result.games[1].players[1].zones['table'].cards[0].faceDown).toBe(true); expect(result.games[1].players[1].zones['table'].cards[0].faceDown).toBe(true);
}); });
it('AttrColor (4) → card.color = attrValue', () => { it('AttrColor (4) → card.color = attrValue', () => {
const result = dispatchAttr(stateWithCard(), CardAttribute.AttrColor, 'red'); const result = dispatchAttr(stateWithCard(), Data.CardAttribute.AttrColor, 'red');
expect(result.games[1].players[1].zones['table'].cards[0].color).toBe('red'); expect(result.games[1].players[1].zones['table'].cards[0].color).toBe('red');
}); });
it('AttrPT (5) → card.pt = attrValue', () => { it('AttrPT (5) → card.pt = attrValue', () => {
const result = dispatchAttr(stateWithCard(), CardAttribute.AttrPT, '2/3'); const result = dispatchAttr(stateWithCard(), Data.CardAttribute.AttrPT, '2/3');
expect(result.games[1].players[1].zones['table'].cards[0].pt).toBe('2/3'); expect(result.games[1].players[1].zones['table'].cards[0].pt).toBe('2/3');
}); });
it('AttrAnnotation (6) → card.annotation = attrValue', () => { it('AttrAnnotation (6) → card.annotation = attrValue', () => {
const result = dispatchAttr(stateWithCard(), CardAttribute.AttrAnnotation, 'enchanted'); const result = dispatchAttr(stateWithCard(), Data.CardAttribute.AttrAnnotation, 'enchanted');
expect(result.games[1].players[1].zones['table'].cards[0].annotation).toBe('enchanted'); expect(result.games[1].players[1].zones['table'].cards[0].annotation).toBe('enchanted');
}); });
it('AttrDoesntUntap (7) → card.doesntUntap = true when attrValue is "1"', () => { it('AttrDoesntUntap (7) → card.doesntUntap = true when attrValue is "1"', () => {
const result = dispatchAttr(stateWithCard(), CardAttribute.AttrDoesntUntap, '1'); const result = dispatchAttr(stateWithCard(), Data.CardAttribute.AttrDoesntUntap, '1');
expect(result.games[1].players[1].zones['table'].cards[0].doesntUntap).toBe(true); expect(result.games[1].players[1].zones['table'].cards[0].doesntUntap).toBe(true);
}); });
}); });

View file

@ -1,12 +1,5 @@
import { CardAttribute } from 'generated/proto/card_attributes_pb'; import { Data } from '@app/types';
import type { ServerInfo_CardCounter } from 'generated/proto/serverinfo_cardcounter_pb';
import type { ServerInfo_Card } from 'generated/proto/serverinfo_card_pb';
import type { ServerInfo_Counter } from 'generated/proto/serverinfo_counter_pb';
import type { ServerInfo_Arrow } from 'generated/proto/serverinfo_arrow_pb';
import type { ServerInfo_Player } from 'generated/proto/serverinfo_player_pb';
import { create } from '@bufbuild/protobuf'; import { create } from '@bufbuild/protobuf';
import { ServerInfo_CardSchema } from 'generated/proto/serverinfo_card_pb';
import { ServerInfo_CardCounterSchema } from 'generated/proto/serverinfo_cardcounter_pb';
import { GameAction } from './game.actions'; import { GameAction } from './game.actions';
import { GameEntry, GameMessage, GamesState, PlayerEntry, ZoneEntry } from './game.interfaces'; import { GameEntry, GameMessage, GamesState, PlayerEntry, ZoneEntry } from './game.interfaces';
import { Types } from './game.types'; import { Types } from './game.types';
@ -74,7 +67,7 @@ function removeGame(state: GamesState, gameId: number): GamesState {
} }
/** Converts the proto PlayerInfo[] array into the keyed PlayerEntry map used in the store. */ /** Converts the proto PlayerInfo[] array into the keyed PlayerEntry map used in the store. */
function normalizePlayers(playerList: ServerInfo_Player[]): { [playerId: number]: PlayerEntry } { function normalizePlayers(playerList: Data.ServerInfo_Player[]): { [playerId: number]: PlayerEntry } {
const players: { [playerId: number]: PlayerEntry } = {}; const players: { [playerId: number]: PlayerEntry } = {};
for (const player of playerList) { for (const player of playerList) {
const playerId = player.properties.playerId; const playerId = player.properties.playerId;
@ -92,12 +85,12 @@ function normalizePlayers(playerList: ServerInfo_Player[]): { [playerId: number]
}; };
} }
const counters: { [counterId: number]: ServerInfo_Counter } = {}; const counters: { [counterId: number]: Data.ServerInfo_Counter } = {};
for (const counter of player.counterList) { for (const counter of player.counterList) {
counters[counter.id] = counter; counters[counter.id] = counter;
} }
const arrows: { [arrowId: number]: ServerInfo_Arrow } = {}; const arrows: { [arrowId: number]: Data.ServerInfo_Arrow } = {};
for (const arrow of player.arrowList) { for (const arrow of player.arrowList) {
arrows[arrow.id] = arrow; arrows[arrow.id] = arrow;
} }
@ -120,8 +113,8 @@ function buildEmptyCard(
y: number, y: number,
faceDown: boolean, faceDown: boolean,
providerId: string providerId: string
): ServerInfo_Card { ): Data.ServerInfo_Card {
return create(ServerInfo_CardSchema, { return create(Data.ServerInfo_CardSchema, {
id, id,
name, name,
x, x,
@ -157,9 +150,31 @@ export const gamesReducer = (state: GamesState = initialState, action: GameActio
} }
case Types.GAME_JOINED: { case Types.GAME_JOINED: {
const { data } = action;
const gameInfo = data.gameInfo;
if (!gameInfo) {
return state;
}
const gameEntry: GameEntry = {
gameId: gameInfo.gameId,
roomId: gameInfo.roomId,
description: gameInfo.description,
hostId: data.hostId,
localPlayerId: data.playerId,
spectator: data.spectator,
judge: data.judge,
resuming: data.resuming,
started: gameInfo.started,
activePlayerId: -1,
activePhase: -1,
secondsElapsed: 0,
reversed: false,
players: {},
messages: [],
};
return { return {
...state, ...state,
games: { ...state.games, [action.gameId]: action.gameEntry }, games: { ...state.games, [gameEntry.gameId]: gameEntry },
}; };
} }
@ -266,8 +281,8 @@ export const gamesReducer = (state: GamesState = initialState, action: GameActio
} }
// Locate card in source zone (by id for visible zones, by position for hidden) // Locate card in source zone (by id for visible zones, by position for hidden)
let removedCard: ServerInfo_Card | undefined; let removedCard: Data.ServerInfo_Card | undefined;
let newSourceCards: ServerInfo_Card[]; let newSourceCards: Data.ServerInfo_Card[];
if (cardId >= 0) { if (cardId >= 0) {
removedCard = sourceZoneEntry.cards.find(c => c.id === cardId); removedCard = sourceZoneEntry.cards.find(c => c.id === cardId);
newSourceCards = sourceZoneEntry.cards.filter(c => c.id !== cardId); newSourceCards = sourceZoneEntry.cards.filter(c => c.id !== cardId);
@ -280,7 +295,7 @@ export const gamesReducer = (state: GamesState = initialState, action: GameActio
} }
const effectiveNewId = newCardId >= 0 ? newCardId : (removedCard?.id ?? -1); const effectiveNewId = newCardId >= 0 ? newCardId : (removedCard?.id ?? -1);
const movedCard: ServerInfo_Card = removedCard const movedCard: Data.ServerInfo_Card = removedCard
? { ? {
...removedCard, ...removedCard,
id: effectiveNewId, id: effectiveNewId,
@ -423,7 +438,7 @@ export const gamesReducer = (state: GamesState = initialState, action: GameActio
return state; return state;
} }
const newCard: ServerInfo_Card = create(ServerInfo_CardSchema, { const newCard: Data.ServerInfo_Card = create(Data.ServerInfo_CardSchema, {
id: cardId, id: cardId,
name: cardName, name: cardName,
x, x,
@ -469,15 +484,15 @@ export const gamesReducer = (state: GamesState = initialState, action: GameActio
return state; return state;
} }
const attrPatch: Partial<ServerInfo_Card> = {}; const attrPatch: Partial<Data.ServerInfo_Card> = {};
switch (attribute as CardAttribute) { switch (attribute as Data.CardAttribute) {
case CardAttribute.AttrTapped: attrPatch.tapped = attrValue === '1'; break; case Data.CardAttribute.AttrTapped: attrPatch.tapped = attrValue === '1'; break;
case CardAttribute.AttrAttacking: attrPatch.attacking = attrValue === '1'; break; case Data.CardAttribute.AttrAttacking: attrPatch.attacking = attrValue === '1'; break;
case CardAttribute.AttrFaceDown: attrPatch.faceDown = attrValue === '1'; break; case Data.CardAttribute.AttrFaceDown: attrPatch.faceDown = attrValue === '1'; break;
case CardAttribute.AttrColor: attrPatch.color = attrValue; break; case Data.CardAttribute.AttrColor: attrPatch.color = attrValue; break;
case CardAttribute.AttrPT: attrPatch.pt = attrValue; break; case Data.CardAttribute.AttrPT: attrPatch.pt = attrValue; break;
case CardAttribute.AttrAnnotation: attrPatch.annotation = attrValue; break; case Data.CardAttribute.AttrAnnotation: attrPatch.annotation = attrValue; break;
case CardAttribute.AttrDoesntUntap: attrPatch.doesntUntap = attrValue === '1'; break; case Data.CardAttribute.AttrDoesntUntap: attrPatch.doesntUntap = attrValue === '1'; break;
} }
const updatedCards = [...zone.cards]; const updatedCards = [...zone.cards];
@ -507,7 +522,7 @@ export const gamesReducer = (state: GamesState = initialState, action: GameActio
} }
const card = zone.cards[cardIdx]; const card = zone.cards[cardIdx];
let newCounterList: ServerInfo_CardCounter[]; let newCounterList: Data.ServerInfo_CardCounter[];
if (counterValue <= 0) { if (counterValue <= 0) {
newCounterList = card.counterList.filter(c => c.id !== counterId); newCounterList = card.counterList.filter(c => c.id !== counterId);
} else { } else {
@ -515,7 +530,7 @@ export const gamesReducer = (state: GamesState = initialState, action: GameActio
newCounterList = newCounterList =
existing >= 0 existing >= 0
? card.counterList.map(c => (c.id === counterId ? { ...c, value: counterValue } : c)) ? card.counterList.map(c => (c.id === counterId ? { ...c, value: counterValue } : c))
: [...card.counterList, create(ServerInfo_CardCounterSchema, { id: counterId, value: counterValue })]; : [...card.counterList, create(Data.ServerInfo_CardCounterSchema, { id: counterId, value: counterValue })];
} }
const updatedCards = [...zone.cards]; const updatedCards = [...zone.cards];

View file

@ -1,12 +1,12 @@
import { createSelector } from '@reduxjs/toolkit'; import { createSelector } from '@reduxjs/toolkit';
import type { ServerInfo_Card } from 'generated/proto/serverinfo_card_pb'; import type { Data } from '@app/types';
import { GamesState, GameEntry, PlayerEntry, ZoneEntry } from './game.interfaces'; import { GamesState, GameEntry, PlayerEntry, ZoneEntry } from './game.interfaces';
interface State { interface State {
games: GamesState; games: GamesState;
} }
const EMPTY_ARRAY: ServerInfo_Card[] = []; const EMPTY_ARRAY: Data.ServerInfo_Card[] = [];
const EMPTY_OBJECT = {} as Record<string, never>; const EMPTY_OBJECT = {} as Record<string, never>;
export const Selectors = { export const Selectors = {

View file

@ -9,7 +9,7 @@ export {
Selectors as GameSelectors, Selectors as GameSelectors,
Dispatch as GameDispatch } from './game'; Dispatch as GameDispatch } from './game';
export * from 'store/game/game.interfaces'; export * from './game/game.interfaces';
// Server // Server
export { export {
@ -17,13 +17,13 @@ export {
Selectors as ServerSelectors, Selectors as ServerSelectors,
Dispatch as ServerDispatch } from './server'; Dispatch as ServerDispatch } from './server';
export * from 'store/server/server.interfaces'; export * from './server/server.interfaces';
export { export {
Types as RoomsTypes, Types as RoomsTypes,
Selectors as RoomsSelectors, Selectors as RoomsSelectors,
Dispatch as RoomsDispatch } from 'store/rooms'; Dispatch as RoomsDispatch } from './rooms';
export * from 'store/rooms/rooms.interfaces'; export * from './rooms/rooms.interfaces';

View file

@ -1,21 +1,13 @@
import { import { App, Data, Enriched } from '@app/types';
Game, import type { MessageInitShape } from '@bufbuild/protobuf';
GameSortField,
Message,
ProtoInit,
Room,
SortDirection,
UserSortField,
} from 'types';
import type { ServerInfo_User } from 'generated/proto/serverinfo_user_pb';
import { create } from '@bufbuild/protobuf'; import { create } from '@bufbuild/protobuf';
import { ServerInfo_UserSchema } from 'generated/proto/serverinfo_user_pb';
import { ServerInfo_GameSchema } from 'generated/proto/serverinfo_game_pb';
import { ServerInfo_RoomSchema } from 'generated/proto/serverinfo_room_pb';
import { RoomsState } from '../rooms.interfaces'; import { RoomsState } from '../rooms.interfaces';
export function makeUser(overrides: ProtoInit<ServerInfo_User> = {}): ServerInfo_User { export function makeUser(
return create(ServerInfo_UserSchema, { overrides: MessageInitShape<typeof Data.ServerInfo_UserSchema> = {}
): Data.ServerInfo_User {
return create(Data.ServerInfo_UserSchema, {
name: 'TestUser', name: 'TestUser',
accountageSecs: 0n, accountageSecs: 0n,
privlevel: '', privlevel: '',
@ -24,10 +16,10 @@ export function makeUser(overrides: ProtoInit<ServerInfo_User> = {}): ServerInfo
}); });
} }
export function makeRoom(overrides: ProtoInit<Room> = {}): Room { export function makeRoom(overrides: Partial<Omit<Enriched.Room, '$typeName' | '$unknown'>> = {}): Enriched.Room {
const { gametypeMap = {}, order = 0, gameList = [], ...protoOverrides } = overrides; const { gametypeMap = {}, order = 0, gameList = [], ...protoOverrides } = overrides;
return { return {
...create(ServerInfo_RoomSchema, { ...create(Data.ServerInfo_RoomSchema, {
roomId: 1, roomId: 1,
name: 'Test Room', name: 'Test Room',
description: '', description: '',
@ -45,10 +37,12 @@ export function makeRoom(overrides: ProtoInit<Room> = {}): Room {
}; };
} }
export function makeGame(overrides: ProtoInit<Game & { startTime: number }> = {}): Game & { startTime: number } { export function makeGame(
overrides: Partial<Omit<Enriched.Game & { startTime: number }, '$typeName' | '$unknown'>> = {},
): Enriched.Game & { startTime: number } {
const { gameType = '', startTime = 0, ...protoOverrides } = overrides; const { gameType = '', startTime = 0, ...protoOverrides } = overrides;
return { return {
...create(ServerInfo_GameSchema, { ...create(Data.ServerInfo_GameSchema, {
gameId: 1, gameId: 1,
roomId: 1, roomId: 1,
description: 'Test Game', description: 'Test Game',
@ -61,7 +55,7 @@ export function makeGame(overrides: ProtoInit<Game & { startTime: number }> = {}
}; };
} }
export function makeMessage(overrides: Partial<Message> = {}): Message { export function makeMessage(overrides: Partial<Enriched.Message> = {}): Enriched.Message {
return { return {
message: 'hello', message: 'hello',
messageType: 0, messageType: 0,
@ -80,12 +74,12 @@ export function makeRoomsState(overrides: Partial<RoomsState> = {}): RoomsState
joinedGameIds: {}, joinedGameIds: {},
messages: {}, messages: {},
sortGamesBy: { sortGamesBy: {
field: GameSortField.START_TIME, field: App.GameSortField.START_TIME,
order: SortDirection.DESC, order: App.SortDirection.DESC,
}, },
sortUsersBy: { sortUsersBy: {
field: UserSortField.NAME, field: App.UserSortField.NAME,
order: SortDirection.ASC, order: App.SortDirection.ASC,
}, },
...overrides, ...overrides,
}; };

View file

@ -1,7 +1,7 @@
import { Actions } from './rooms.actions'; import { Actions } from './rooms.actions';
import { Types } from './rooms.types'; import { Types } from './rooms.types';
import { makeGame, makeMessage, makeRoom, makeUser } from './__mocks__/rooms-fixtures'; import { makeGame, makeMessage, makeRoom, makeUser } from './__mocks__/rooms-fixtures';
import { GameSortField, SortDirection } from 'types'; import { App } from '@app/types';
describe('Actions', () => { describe('Actions', () => {
it('clearStore', () => { it('clearStore', () => {
@ -42,11 +42,11 @@ describe('Actions', () => {
}); });
it('sortGames', () => { it('sortGames', () => {
expect(Actions.sortGames(1, GameSortField.START_TIME, SortDirection.ASC)).toEqual({ expect(Actions.sortGames(1, App.GameSortField.START_TIME, App.SortDirection.ASC)).toEqual({
type: Types.SORT_GAMES, type: Types.SORT_GAMES,
roomId: 1, roomId: 1,
field: GameSortField.START_TIME, field: App.GameSortField.START_TIME,
order: SortDirection.ASC, order: App.SortDirection.ASC,
}); });
}); });

View file

@ -1,7 +1,4 @@
import { GameSortField, Message, SortDirection } from 'types'; import { App, Data, Enriched } from '@app/types';
import type { ServerInfo_User } from 'generated/proto/serverinfo_user_pb';
import type { ServerInfo_Room } from 'generated/proto/serverinfo_room_pb';
import type { ServerInfo_Game } from 'generated/proto/serverinfo_game_pb';
import { Types } from './rooms.types'; import { Types } from './rooms.types';
@ -10,12 +7,12 @@ export const Actions = {
type: Types.CLEAR_STORE, type: Types.CLEAR_STORE,
}), }),
updateRooms: (rooms: ServerInfo_Room[]) => ({ updateRooms: (rooms: Data.ServerInfo_Room[]) => ({
type: Types.UPDATE_ROOMS, type: Types.UPDATE_ROOMS,
rooms, rooms,
}), }),
joinRoom: (roomInfo: ServerInfo_Room) => ({ joinRoom: (roomInfo: Data.ServerInfo_Room) => ({
type: Types.JOIN_ROOM, type: Types.JOIN_ROOM,
roomInfo, roomInfo,
}), }),
@ -25,19 +22,19 @@ export const Actions = {
roomId, roomId,
}), }),
addMessage: (roomId: number, message: Message) => ({ addMessage: (roomId: number, message: Enriched.Message) => ({
type: Types.ADD_MESSAGE, type: Types.ADD_MESSAGE,
roomId, roomId,
message, message,
}), }),
updateGames: (roomId: number, games: ServerInfo_Game[]) => ({ updateGames: (roomId: number, games: Data.ServerInfo_Game[]) => ({
type: Types.UPDATE_GAMES, type: Types.UPDATE_GAMES,
roomId, roomId,
games, games,
}), }),
userJoined: (roomId: number, user: ServerInfo_User) => ({ userJoined: (roomId: number, user: Data.ServerInfo_User) => ({
type: Types.USER_JOINED, type: Types.USER_JOINED,
roomId, roomId,
user, user,
@ -49,7 +46,7 @@ export const Actions = {
name, name,
}), }),
sortGames: (roomId: number, field: GameSortField, order: SortDirection) => ({ sortGames: (roomId: number, field: App.GameSortField, order: App.SortDirection) => ({
type: Types.SORT_GAMES, type: Types.SORT_GAMES,
roomId, roomId,
field, field,

View file

@ -1,12 +1,10 @@
vi.mock('store', () => ({ store: { dispatch: vi.fn() } })); vi.mock('..', () => ({ store: { dispatch: vi.fn() } }));
import { store } from 'store'; import { store } from '..';
import { Actions } from './rooms.actions'; import { Actions } from './rooms.actions';
import { Dispatch } from './rooms.dispatch'; import { Dispatch } from './rooms.dispatch';
import { makeGame, makeMessage, makeRoom, makeUser } from './__mocks__/rooms-fixtures'; import { makeGame, makeMessage, makeRoom, makeUser } from './__mocks__/rooms-fixtures';
import { GameSortField, SortDirection } from 'types'; import { App } from '@app/types';
beforeEach(() => vi.clearAllMocks());
describe('Dispatch', () => { describe('Dispatch', () => {
it('clearStore dispatches Actions.clearStore()', () => { it('clearStore dispatches Actions.clearStore()', () => {
@ -63,9 +61,9 @@ describe('Dispatch', () => {
}); });
it('sortGames dispatches Actions.sortGames()', () => { it('sortGames dispatches Actions.sortGames()', () => {
Dispatch.sortGames(1, GameSortField.START_TIME, SortDirection.ASC); Dispatch.sortGames(1, App.GameSortField.START_TIME, App.SortDirection.ASC);
expect(store.dispatch).toHaveBeenCalledWith( expect(store.dispatch).toHaveBeenCalledWith(
Actions.sortGames(1, GameSortField.START_TIME, SortDirection.ASC) Actions.sortGames(1, App.GameSortField.START_TIME, App.SortDirection.ASC)
); );
}); });

View file

@ -1,21 +1,18 @@
import { GameSortField, Message, SortDirection } from 'types'; import { App, Data, Enriched } from '@app/types';
import type { ServerInfo_User } from 'generated/proto/serverinfo_user_pb';
import type { ServerInfo_Room } from 'generated/proto/serverinfo_room_pb';
import type { ServerInfo_Game } from 'generated/proto/serverinfo_game_pb';
import { Actions } from './rooms.actions'; import { Actions } from './rooms.actions';
import { store } from 'store'; import { store } from '..';
export const Dispatch = { export const Dispatch = {
clearStore: () => { clearStore: () => {
store.dispatch(Actions.clearStore()); store.dispatch(Actions.clearStore());
}, },
updateRooms: (rooms: ServerInfo_Room[]) => { updateRooms: (rooms: Data.ServerInfo_Room[]) => {
store.dispatch(Actions.updateRooms(rooms)); store.dispatch(Actions.updateRooms(rooms));
}, },
joinRoom: (roomInfo: ServerInfo_Room) => { joinRoom: (roomInfo: Data.ServerInfo_Room) => {
store.dispatch(Actions.joinRoom(roomInfo)); store.dispatch(Actions.joinRoom(roomInfo));
}, },
@ -24,15 +21,15 @@ export const Dispatch = {
store.dispatch(Actions.leaveRoom(roomId)); store.dispatch(Actions.leaveRoom(roomId));
}, },
addMessage: (roomId: number, message: Message) => { addMessage: (roomId: number, message: Enriched.Message) => {
store.dispatch(Actions.addMessage(roomId, message)); store.dispatch(Actions.addMessage(roomId, message));
}, },
updateGames: (roomId: number, games: ServerInfo_Game[]) => { updateGames: (roomId: number, games: Data.ServerInfo_Game[]) => {
store.dispatch(Actions.updateGames(roomId, games)); store.dispatch(Actions.updateGames(roomId, games));
}, },
userJoined: (roomId: number, user: ServerInfo_User) => { userJoined: (roomId: number, user: Data.ServerInfo_User) => {
store.dispatch(Actions.userJoined(roomId, user)); store.dispatch(Actions.userJoined(roomId, user));
}, },
@ -40,7 +37,7 @@ export const Dispatch = {
store.dispatch(Actions.userLeft(roomId, name)); store.dispatch(Actions.userLeft(roomId, name));
}, },
sortGames: (roomId: number, field: GameSortField, order: SortDirection) => { sortGames: (roomId: number, field: App.GameSortField, order: App.SortDirection) => {
store.dispatch(Actions.sortGames(roomId, field, order)); store.dispatch(Actions.sortGames(roomId, field, order));
}, },

View file

@ -1,4 +1,4 @@
import { GameSortField, Message, Room, Game, SortBy, UserSortField } from 'types'; import { App, Enriched } from '@app/types';
export interface RoomsState { export interface RoomsState {
rooms: RoomsStateRooms; rooms: RoomsStateRooms;
@ -11,12 +11,12 @@ export interface RoomsState {
} }
export interface RoomsStateRooms { export interface RoomsStateRooms {
[roomId: number]: Room; [roomId: number]: Enriched.Room;
} }
export interface RoomsStateGames { export interface RoomsStateGames {
[roomId: number]: { [roomId: number]: {
[gameId: number]: Game; [gameId: number]: Enriched.Game;
}; };
} }
@ -31,13 +31,13 @@ export interface JoinedGames {
} }
export interface RoomsStateMessages { export interface RoomsStateMessages {
[roomId: number]: Message[]; [roomId: number]: Enriched.Message[];
} }
export interface RoomsStateSortGamesBy extends SortBy { export interface RoomsStateSortGamesBy extends App.SortBy {
field: GameSortField field: App.GameSortField
} }
export interface RoomsStateSortUsersBy extends SortBy { export interface RoomsStateSortUsersBy extends App.SortBy {
field: UserSortField field: App.UserSortField
} }

View file

@ -1,4 +1,4 @@
import { GameSortField, SortDirection } from 'types'; import { App } from '@app/types';
import { roomsReducer } from './rooms.reducer'; import { roomsReducer } from './rooms.reducer';
import { Types, MAX_ROOM_MESSAGES } from './rooms.types'; import { Types, MAX_ROOM_MESSAGES } from './rooms.types';
import { makeGame, makeMessage, makeRoom, makeRoomsState, makeUser } from './__mocks__/rooms-fixtures'; import { makeGame, makeMessage, makeRoom, makeRoomsState, makeUser } from './__mocks__/rooms-fixtures';
@ -191,11 +191,10 @@ describe('UPDATE_GAMES', () => {
expect(result.rooms[1].gameList.find(g => g.gameId === 2).description).toBe('new'); expect(result.rooms[1].gameList.find(g => g.gameId === 2).description).toBe('new');
}); });
it('returns { ...state } (not identity) when roomId is unknown', () => { it('returns state identity when roomId is unknown', () => {
const state = makeRoomsState({ rooms: {} }); const state = makeRoomsState({ rooms: {} });
const result = roomsReducer(state, { type: Types.UPDATE_GAMES, roomId: 999, games: [] }); const result = roomsReducer(state, { type: Types.UPDATE_GAMES, roomId: 999, games: [] });
expect(result).not.toBe(state); expect(result).toBe(state);
expect(result.rooms).toEqual(state.rooms);
}); });
}); });
@ -231,10 +230,10 @@ describe('SORT_GAMES', () => {
const result = roomsReducer(state, { const result = roomsReducer(state, {
type: Types.SORT_GAMES, type: Types.SORT_GAMES,
roomId: 1, roomId: 1,
field: GameSortField.START_TIME, field: App.GameSortField.START_TIME,
order: SortDirection.ASC, order: App.SortDirection.ASC,
}); });
expect(result.sortGamesBy).toEqual({ field: GameSortField.START_TIME, order: SortDirection.ASC }); expect(result.sortGamesBy).toEqual({ field: App.GameSortField.START_TIME, order: App.SortDirection.ASC });
}); });
}); });

View file

@ -1,6 +1,6 @@
import * as _ from 'lodash'; import * as _ from 'lodash';
import { GameSortField, Room, UserSortField, SortDirection } from 'types'; import { App, Enriched } from '@app/types';
import { normalizeGameObject, normalizeGametypeMap, normalizeRoomInfo, normalizeUserMessage, SortUtil } from '../common'; import { normalizeGameObject, normalizeGametypeMap, normalizeRoomInfo, normalizeUserMessage, SortUtil } from '../common';
@ -15,12 +15,12 @@ const initialState: RoomsState = {
joinedGameIds: {}, joinedGameIds: {},
messages: {}, messages: {},
sortGamesBy: { sortGamesBy: {
field: GameSortField.START_TIME, field: App.GameSortField.START_TIME,
order: SortDirection.DESC order: App.SortDirection.DESC
}, },
sortUsersBy: { sortUsersBy: {
field: UserSortField.NAME, field: App.UserSortField.NAME,
order: SortDirection.ASC order: App.SortDirection.ASC
} }
}; };
@ -46,11 +46,11 @@ export const roomsReducer = (state = initialState, action: RoomsAction) => {
const gametypeMap = normalizeGametypeMap(gametypeList); const gametypeMap = normalizeGametypeMap(gametypeList);
rooms[roomId] = { rooms[roomId] = {
...(existing as Room), ...(existing as Enriched.Room),
...roomMeta, ...roomMeta,
gametypeMap, gametypeMap,
gameList: (existing as Room).gameList, gameList: (existing as Enriched.Room).gameList,
userList: (existing as Room).userList, userList: (existing as Enriched.Room).userList,
order, order,
}; };
}); });
@ -149,8 +149,10 @@ export const roomsReducer = (state = initialState, action: RoomsAction) => {
const { rooms, sortGamesBy } = state; const { rooms, sortGamesBy } = state;
const room = rooms[roomId]; const room = rooms[roomId];
if (!room) { // An empty gameList means no game updates — skip to avoid
return { ...state }; // overwriting the existing game list with an empty one.
if (!room || !games?.length) {
return state;
} }
// Normalize incoming raw proto games using the room's gametypeMap // Normalize incoming raw proto games using the room's gametypeMap

View file

@ -1,33 +1,13 @@
import { import { App, Data, Enriched } from '@app/types';
Game, import type { MessageInitShape } from '@bufbuild/protobuf';
ProtoInit,
SortDirection,
StatusEnum,
UserSortField,
WebSocketConnectOptions,
} from 'types';
import type { ServerInfo_User } from 'generated/proto/serverinfo_user_pb';
import type { ServerInfo_Ban } from 'generated/proto/serverinfo_ban_pb';
import type { ServerInfo_Warning } from 'generated/proto/serverinfo_warning_pb';
import type { Response_WarnList } from 'generated/proto/response_warn_list_pb';
import type { ServerInfo_ReplayMatch } from 'generated/proto/serverinfo_replay_match_pb';
import type { ServerInfo_ChatMessage } from 'generated/proto/serverinfo_chat_message_pb';
import type { Response_DeckList } from 'generated/proto/response_deck_list_pb';
import type { ServerInfo_DeckStorage_TreeItem } from 'generated/proto/serverinfo_deckstorage_pb';
import { create } from '@bufbuild/protobuf'; import { create } from '@bufbuild/protobuf';
import { ServerInfo_GameSchema } from 'generated/proto/serverinfo_game_pb';
import { ServerInfo_UserSchema } from 'generated/proto/serverinfo_user_pb';
import { ServerInfo_ReplayMatchSchema } from 'generated/proto/serverinfo_replay_match_pb';
import { ServerInfo_ChatMessageSchema } from 'generated/proto/serverinfo_chat_message_pb';
import { ServerInfo_BanSchema } from 'generated/proto/serverinfo_ban_pb';
import { ServerInfo_WarningSchema } from 'generated/proto/serverinfo_warning_pb';
import { Response_WarnListSchema } from 'generated/proto/response_warn_list_pb';
import { ServerInfo_DeckStorage_TreeItemSchema, ServerInfo_DeckStorage_FolderSchema } from 'generated/proto/serverinfo_deckstorage_pb';
import { Response_DeckListSchema } from 'generated/proto/response_deck_list_pb';
import { ServerState } from '../server.interfaces'; import { ServerState } from '../server.interfaces';
export function makeUser(overrides: ProtoInit<ServerInfo_User> = {}): ServerInfo_User { export function makeUser(
return create(ServerInfo_UserSchema, { overrides: MessageInitShape<typeof Data.ServerInfo_UserSchema> = {}
): Data.ServerInfo_User {
return create(Data.ServerInfo_UserSchema, {
name: 'TestUser', name: 'TestUser',
accountageSecs: 0n, accountageSecs: 0n,
privlevel: '', privlevel: '',
@ -36,8 +16,10 @@ export function makeUser(overrides: ProtoInit<ServerInfo_User> = {}): ServerInfo
}); });
} }
export function makeLogItem(overrides: ProtoInit<ServerInfo_ChatMessage> = {}): ServerInfo_ChatMessage { export function makeLogItem(
return create(ServerInfo_ChatMessageSchema, { overrides: MessageInitShape<typeof Data.ServerInfo_ChatMessageSchema> = {}
): Data.ServerInfo_ChatMessage {
return create(Data.ServerInfo_ChatMessageSchema, {
message: '', message: '',
senderId: '', senderId: '',
senderIp: '', senderIp: '',
@ -50,8 +32,10 @@ export function makeLogItem(overrides: ProtoInit<ServerInfo_ChatMessage> = {}):
}); });
} }
export function makeBanHistoryItem(overrides: ProtoInit<ServerInfo_Ban> = {}): ServerInfo_Ban { export function makeBanHistoryItem(
return create(ServerInfo_BanSchema, { overrides: MessageInitShape<typeof Data.ServerInfo_BanSchema> = {}
): Data.ServerInfo_Ban {
return create(Data.ServerInfo_BanSchema, {
adminId: '', adminId: '',
adminName: '', adminName: '',
banTime: '', banTime: '',
@ -62,8 +46,10 @@ export function makeBanHistoryItem(overrides: ProtoInit<ServerInfo_Ban> = {}): S
}); });
} }
export function makeWarnHistoryItem(overrides: ProtoInit<ServerInfo_Warning> = {}): ServerInfo_Warning { export function makeWarnHistoryItem(
return create(ServerInfo_WarningSchema, { overrides: MessageInitShape<typeof Data.ServerInfo_WarningSchema> = {}
): Data.ServerInfo_Warning {
return create(Data.ServerInfo_WarningSchema, {
userName: '', userName: '',
adminName: '', adminName: '',
reason: '', reason: '',
@ -72,8 +58,10 @@ export function makeWarnHistoryItem(overrides: ProtoInit<ServerInfo_Warning> = {
}); });
} }
export function makeWarnListItem(overrides: ProtoInit<Response_WarnList> = {}): Response_WarnList { export function makeWarnListItem(
return create(Response_WarnListSchema, { overrides: MessageInitShape<typeof Data.Response_WarnListSchema> = {}
): Data.Response_WarnList {
return create(Data.Response_WarnListSchema, {
warning: [], warning: [],
userName: '', userName: '',
userClientid: '', userClientid: '',
@ -81,23 +69,29 @@ export function makeWarnListItem(overrides: ProtoInit<Response_WarnList> = {}):
}); });
} }
export function makeDeckTreeItem(overrides: ProtoInit<ServerInfo_DeckStorage_TreeItem> = {}): ServerInfo_DeckStorage_TreeItem { export function makeDeckTreeItem(
return create(ServerInfo_DeckStorage_TreeItemSchema, { overrides: MessageInitShape<typeof Data.ServerInfo_DeckStorage_TreeItemSchema> = {},
): Data.ServerInfo_DeckStorage_TreeItem {
return create(Data.ServerInfo_DeckStorage_TreeItemSchema, {
id: 1, id: 1,
name: 'item', name: 'item',
...overrides, ...overrides,
}); });
} }
export function makeDeckList(overrides: ProtoInit<Response_DeckList> = {}): Response_DeckList { export function makeDeckList(
return create(Response_DeckListSchema, { overrides: MessageInitShape<typeof Data.Response_DeckListSchema> = {}
root: create(ServerInfo_DeckStorage_FolderSchema, { items: [] }), ): Data.Response_DeckList {
return create(Data.Response_DeckListSchema, {
root: create(Data.ServerInfo_DeckStorage_FolderSchema, { items: [] }),
...overrides, ...overrides,
}); });
} }
export function makeReplayMatch(overrides: ProtoInit<ServerInfo_ReplayMatch> = {}): ServerInfo_ReplayMatch { export function makeReplayMatch(
return create(ServerInfo_ReplayMatchSchema, { overrides: MessageInitShape<typeof Data.ServerInfo_ReplayMatchSchema> = {}
): Data.ServerInfo_ReplayMatch {
return create(Data.ServerInfo_ReplayMatchSchema, {
gameId: 1, gameId: 1,
roomName: 'Test Room', roomName: 'Test Room',
timeStarted: 0, timeStarted: 0,
@ -110,16 +104,26 @@ export function makeReplayMatch(overrides: ProtoInit<ServerInfo_ReplayMatch> = {
}); });
} }
export function makeGame(overrides: Partial<Game> = {}): Game { export function makeGame(overrides: Partial<Enriched.Game> = {}): Enriched.Game {
return { ...create(ServerInfo_GameSchema, { description: '' }), gameType: '', ...overrides }; return { ...create(Data.ServerInfo_GameSchema, { description: '' }), gameType: '', ...overrides };
} }
export function makeConnectOptions(overrides: Partial<WebSocketConnectOptions> = {}): WebSocketConnectOptions { export function makeLoginSuccessContext(
overrides: Partial<Enriched.LoginSuccessContext> = {}
): Enriched.LoginSuccessContext {
return {
hashedPassword: 'hash',
...overrides,
};
}
export function makePendingActivationContext(
overrides: Partial<Enriched.PendingActivationContext> = {}
): Enriched.PendingActivationContext {
return { return {
host: 'localhost', host: 'localhost',
port: '4747', port: '4747',
userName: 'user', userName: 'user',
password: 'pass',
...overrides, ...overrides,
}; };
} }
@ -131,7 +135,7 @@ export function makeServerState(overrides: Partial<ServerState> = {}): ServerSta
ignoreList: [], ignoreList: [],
status: { status: {
connectionAttemptMade: false, connectionAttemptMade: false,
state: StatusEnum.DISCONNECTED, state: App.StatusEnum.DISCONNECTED,
description: null, description: null,
}, },
info: { info: {
@ -147,8 +151,8 @@ export function makeServerState(overrides: Partial<ServerState> = {}): ServerSta
user: null, user: null,
users: [], users: [],
sortUsersBy: { sortUsersBy: {
field: UserSortField.NAME, field: App.UserSortField.NAME,
order: SortDirection.ASC, order: App.SortDirection.ASC,
}, },
messages: {}, messages: {},
userInfo: {}, userInfo: {},

View file

@ -1,16 +1,14 @@
import { Actions } from './server.actions'; import { Actions } from './server.actions';
import { App, Data } from '@app/types';
import { Types } from './server.types'; import { Types } from './server.types';
import { create } from '@bufbuild/protobuf'; import { create } from '@bufbuild/protobuf';
import { Event_NotifyUserSchema } from 'generated/proto/event_notify_user_pb';
import { Event_ServerShutdownSchema } from 'generated/proto/event_server_shutdown_pb';
import { Event_UserMessageSchema } from 'generated/proto/event_user_message_pb';
import { import {
makeBanHistoryItem, makeBanHistoryItem,
makeConnectOptions, makeLoginSuccessContext,
makePendingActivationContext,
makeDeckList, makeDeckList,
makeDeckTreeItem, makeDeckTreeItem,
makeReplayMatch, makeReplayMatch,
makeGame,
makeUser, makeUser,
makeWarnHistoryItem, makeWarnHistoryItem,
makeWarnListItem, makeWarnListItem,
@ -30,7 +28,7 @@ describe('Actions', () => {
}); });
it('loginSuccessful', () => { it('loginSuccessful', () => {
const options = makeConnectOptions(); const options = makeLoginSuccessContext();
expect(Actions.loginSuccessful(options)).toEqual({ type: Types.LOGIN_SUCCESSFUL, options }); expect(Actions.loginSuccessful(options)).toEqual({ type: Types.LOGIN_SUCCESSFUL, options });
}); });
@ -38,10 +36,6 @@ describe('Actions', () => {
expect(Actions.loginFailed()).toEqual({ type: Types.LOGIN_FAILED }); expect(Actions.loginFailed()).toEqual({ type: Types.LOGIN_FAILED });
}); });
it('connectionClosed', () => {
expect(Actions.connectionClosed(3)).toEqual({ type: Types.CONNECTION_CLOSED, reason: 3 });
});
it('connectionFailed', () => { it('connectionFailed', () => {
expect(Actions.connectionFailed()).toEqual({ type: Types.CONNECTION_FAILED }); expect(Actions.connectionFailed()).toEqual({ type: Types.CONNECTION_FAILED });
}); });
@ -92,7 +86,7 @@ describe('Actions', () => {
}); });
it('updateStatus', () => { it('updateStatus', () => {
const status = { state: 2, description: 'connected' }; const status = { state: App.StatusEnum.CONNECTED, description: 'connected' };
expect(Actions.updateStatus(status)).toEqual({ type: Types.UPDATE_STATUS, status }); expect(Actions.updateStatus(status)).toEqual({ type: Types.UPDATE_STATUS, status });
}); });
@ -116,7 +110,7 @@ describe('Actions', () => {
}); });
it('viewLogs', () => { it('viewLogs', () => {
const logs = [{ targetType: 'room' }] as any[]; const logs = [create(Data.ServerInfo_ChatMessageSchema, { targetType: 'room' })];
expect(Actions.viewLogs(logs)).toEqual({ type: Types.VIEW_LOGS, logs }); expect(Actions.viewLogs(logs)).toEqual({ type: Types.VIEW_LOGS, logs });
}); });
@ -153,7 +147,7 @@ describe('Actions', () => {
}); });
it('accountAwaitingActivation', () => { it('accountAwaitingActivation', () => {
const options = makeConnectOptions(); const options = makePendingActivationContext();
expect(Actions.accountAwaitingActivation(options)).toEqual({ type: Types.ACCOUNT_AWAITING_ACTIVATION, options }); expect(Actions.accountAwaitingActivation(options)).toEqual({ type: Types.ACCOUNT_AWAITING_ACTIVATION, options });
}); });
@ -222,17 +216,17 @@ describe('Actions', () => {
}); });
it('notifyUser', () => { it('notifyUser', () => {
const notification = create(Event_NotifyUserSchema, { type: 1, warningReason: '', customTitle: '', customContent: '' }); const notification = create(Data.Event_NotifyUserSchema, { type: 1, warningReason: '', customTitle: '', customContent: '' });
expect(Actions.notifyUser(notification)).toEqual({ type: Types.NOTIFY_USER, notification }); expect(Actions.notifyUser(notification)).toEqual({ type: Types.NOTIFY_USER, notification });
}); });
it('serverShutdown', () => { it('serverShutdown', () => {
const data = create(Event_ServerShutdownSchema, { reason: 'maintenance', minutes: 5 }); const data = create(Data.Event_ServerShutdownSchema, { reason: 'maintenance', minutes: 5 });
expect(Actions.serverShutdown(data)).toEqual({ type: Types.SERVER_SHUTDOWN, data }); expect(Actions.serverShutdown(data)).toEqual({ type: Types.SERVER_SHUTDOWN, data });
}); });
it('userMessage', () => { it('userMessage', () => {
const messageData = create(Event_UserMessageSchema, { senderName: 'Alice', receiverName: 'Bob', message: 'hey' }); const messageData = create(Data.Event_UserMessageSchema, { senderName: 'Alice', receiverName: 'Bob', message: 'hey' });
expect(Actions.userMessage(messageData)).toEqual({ type: Types.USER_MESSAGE, messageData }); expect(Actions.userMessage(messageData)).toEqual({ type: Types.USER_MESSAGE, messageData });
}); });
@ -360,9 +354,8 @@ describe('Actions', () => {
}); });
it('gamesOfUser', () => { it('gamesOfUser', () => {
const games = [makeGame({ gameId: 1 })]; const response = create(Data.Response_GetGamesOfUserSchema, { roomList: [], gameList: [] });
const gametypeMap = { 1: 'Standard' }; expect(Actions.gamesOfUser('alice', response)).toEqual({ type: Types.GAMES_OF_USER, userName: 'alice', response });
expect(Actions.gamesOfUser('alice', games, gametypeMap)).toEqual({ type: Types.GAMES_OF_USER, userName: 'alice', games, gametypeMap });
}); });
it('clearRegistrationErrors', () => { it('clearRegistrationErrors', () => {

View file

@ -1,16 +1,4 @@
import { import { Data, Enriched } from '@app/types';
GametypeMap, WebSocketConnectOptions
} from 'types';
import type { ServerInfo_User } from 'generated/proto/serverinfo_user_pb';
import type { ServerInfo_Ban } from 'generated/proto/serverinfo_ban_pb';
import type { ServerInfo_Warning } from 'generated/proto/serverinfo_warning_pb';
import type { Response_WarnList } from 'generated/proto/response_warn_list_pb';
import type { ServerInfo_ReplayMatch } from 'generated/proto/serverinfo_replay_match_pb';
import type { Response_DeckList } from 'generated/proto/response_deck_list_pb';
import type { ServerInfo_DeckStorage_TreeItem } from 'generated/proto/serverinfo_deckstorage_pb';
import type { ServerInfo_ChatMessage } from 'generated/proto/serverinfo_chat_message_pb';
import type { ServerInfo_Game } from 'generated/proto/serverinfo_game_pb';
import { NotifyUserData, ServerShutdownData, UserMessageData } from 'websocket/events/session/interfaces';
import { ServerStateStatus } from './server.interfaces'; import { ServerStateStatus } from './server.interfaces';
import { Types } from './server.types'; import { Types } from './server.types';
@ -24,17 +12,13 @@ export const Actions = {
connectionAttempted: () => ({ connectionAttempted: () => ({
type: Types.CONNECTION_ATTEMPTED type: Types.CONNECTION_ATTEMPTED
}), }),
loginSuccessful: (options: WebSocketConnectOptions) => ({ loginSuccessful: (options: Enriched.LoginSuccessContext) => ({
type: Types.LOGIN_SUCCESSFUL, type: Types.LOGIN_SUCCESSFUL,
options options
}), }),
loginFailed: () => ({ loginFailed: () => ({
type: Types.LOGIN_FAILED, type: Types.LOGIN_FAILED,
}), }),
connectionClosed: (reason: number) => ({
type: Types.CONNECTION_CLOSED,
reason
}),
connectionFailed: () => ({ connectionFailed: () => ({
type: Types.CONNECTION_FAILED, type: Types.CONNECTION_FAILED,
}), }),
@ -48,11 +32,11 @@ export const Actions = {
type: Types.SERVER_MESSAGE, type: Types.SERVER_MESSAGE,
message message
}), }),
updateBuddyList: (buddyList: ServerInfo_User[]) => ({ updateBuddyList: (buddyList: Data.ServerInfo_User[]) => ({
type: Types.UPDATE_BUDDY_LIST, type: Types.UPDATE_BUDDY_LIST,
buddyList buddyList
}), }),
addToBuddyList: (user: ServerInfo_User) => ({ addToBuddyList: (user: Data.ServerInfo_User) => ({
type: Types.ADD_TO_BUDDY_LIST, type: Types.ADD_TO_BUDDY_LIST,
user user
}), }),
@ -60,11 +44,11 @@ export const Actions = {
type: Types.REMOVE_FROM_BUDDY_LIST, type: Types.REMOVE_FROM_BUDDY_LIST,
userName userName
}), }),
updateIgnoreList: (ignoreList: ServerInfo_User[]) => ({ updateIgnoreList: (ignoreList: Data.ServerInfo_User[]) => ({
type: Types.UPDATE_IGNORE_LIST, type: Types.UPDATE_IGNORE_LIST,
ignoreList ignoreList
}), }),
addToIgnoreList: (user: ServerInfo_User) => ({ addToIgnoreList: (user: Data.ServerInfo_User) => ({
type: Types.ADD_TO_IGNORE_LIST, type: Types.ADD_TO_IGNORE_LIST,
user user
}), }),
@ -76,19 +60,19 @@ export const Actions = {
type: Types.UPDATE_INFO, type: Types.UPDATE_INFO,
info info
}), }),
updateStatus: (status: ServerStateStatus) => ({ updateStatus: (status: Pick<ServerStateStatus, 'state' | 'description'>) => ({
type: Types.UPDATE_STATUS, type: Types.UPDATE_STATUS,
status status
}), }),
updateUser: (user: ServerInfo_User) => ({ updateUser: (user: Data.ServerInfo_User) => ({
type: Types.UPDATE_USER, type: Types.UPDATE_USER,
user user
}), }),
updateUsers: (users: ServerInfo_User[]) => ({ updateUsers: (users: Data.ServerInfo_User[]) => ({
type: Types.UPDATE_USERS, type: Types.UPDATE_USERS,
users users
}), }),
userJoined: (user: ServerInfo_User) => ({ userJoined: (user: Data.ServerInfo_User) => ({
type: Types.USER_JOINED, type: Types.USER_JOINED,
user user
}), }),
@ -96,7 +80,7 @@ export const Actions = {
type: Types.USER_LEFT, type: Types.USER_LEFT,
name name
}), }),
viewLogs: (logs: ServerInfo_ChatMessage[]) => ({ viewLogs: (logs: Data.ServerInfo_ChatMessage[]) => ({
type: Types.VIEW_LOGS, type: Types.VIEW_LOGS,
logs logs
}), }),
@ -129,7 +113,7 @@ export const Actions = {
clearRegistrationErrors: () => ({ clearRegistrationErrors: () => ({
type: Types.CLEAR_REGISTRATION_ERRORS, type: Types.CLEAR_REGISTRATION_ERRORS,
}), }),
accountAwaitingActivation: (options: WebSocketConnectOptions) => ({ accountAwaitingActivation: (options: Enriched.PendingActivationContext) => ({
type: Types.ACCOUNT_AWAITING_ACTIVATION, type: Types.ACCOUNT_AWAITING_ACTIVATION,
options options
}), }),
@ -169,27 +153,27 @@ export const Actions = {
accountPasswordChange: () => ({ accountPasswordChange: () => ({
type: Types.ACCOUNT_PASSWORD_CHANGE, type: Types.ACCOUNT_PASSWORD_CHANGE,
}), }),
accountEditChanged: (user: Partial<ServerInfo_User>) => ({ accountEditChanged: (user: Partial<Data.ServerInfo_User>) => ({
type: Types.ACCOUNT_EDIT_CHANGED, type: Types.ACCOUNT_EDIT_CHANGED,
user, user,
}), }),
accountImageChanged: (user: Partial<ServerInfo_User>) => ({ accountImageChanged: (user: Partial<Data.ServerInfo_User>) => ({
type: Types.ACCOUNT_IMAGE_CHANGED, type: Types.ACCOUNT_IMAGE_CHANGED,
user, user,
}), }),
getUserInfo: (userInfo: ServerInfo_User) => ({ getUserInfo: (userInfo: Data.ServerInfo_User) => ({
type: Types.GET_USER_INFO, type: Types.GET_USER_INFO,
userInfo, userInfo,
}), }),
notifyUser: (notification: NotifyUserData) => ({ notifyUser: (notification: Data.Event_NotifyUser) => ({
type: Types.NOTIFY_USER, type: Types.NOTIFY_USER,
notification, notification,
}), }),
serverShutdown: (data: ServerShutdownData) => ({ serverShutdown: (data: Data.Event_ServerShutdown) => ({
type: Types.SERVER_SHUTDOWN, type: Types.SERVER_SHUTDOWN,
data, data,
}), }),
userMessage: (messageData: UserMessageData) => ({ userMessage: (messageData: Data.Event_UserMessage) => ({
type: Types.USER_MESSAGE, type: Types.USER_MESSAGE,
messageData, messageData,
}), }),
@ -207,17 +191,17 @@ export const Actions = {
type: Types.BAN_FROM_SERVER, type: Types.BAN_FROM_SERVER,
userName, userName,
}), }),
banHistory: (userName: string, banHistory: ServerInfo_Ban[]) => ({ banHistory: (userName: string, banHistory: Data.ServerInfo_Ban[]) => ({
type: Types.BAN_HISTORY, type: Types.BAN_HISTORY,
userName, userName,
banHistory, banHistory,
}), }),
warnHistory: (userName: string, warnHistory: ServerInfo_Warning[]) => ({ warnHistory: (userName: string, warnHistory: Data.ServerInfo_Warning[]) => ({
type: Types.WARN_HISTORY, type: Types.WARN_HISTORY,
userName, userName,
warnHistory, warnHistory,
}), }),
warnListOptions: (warnList: Response_WarnList[]) => ({ warnListOptions: (warnList: Data.Response_WarnList[]) => ({
type: Types.WARN_LIST_OPTIONS, type: Types.WARN_LIST_OPTIONS,
warnList, warnList,
}), }),
@ -245,17 +229,17 @@ export const Actions = {
userName, userName,
notes, notes,
}), }),
replayList: (matchList: ServerInfo_ReplayMatch[]) => ({ type: Types.REPLAY_LIST, matchList }), replayList: (matchList: Data.ServerInfo_ReplayMatch[]) => ({ type: Types.REPLAY_LIST, matchList }),
replayAdded: (matchInfo: ServerInfo_ReplayMatch) => ({ type: Types.REPLAY_ADDED, matchInfo }), replayAdded: (matchInfo: Data.ServerInfo_ReplayMatch) => ({ type: Types.REPLAY_ADDED, matchInfo }),
replayModifyMatch: (gameId: number, doNotHide: boolean) => ({ type: Types.REPLAY_MODIFY_MATCH, gameId, doNotHide }), replayModifyMatch: (gameId: number, doNotHide: boolean) => ({ type: Types.REPLAY_MODIFY_MATCH, gameId, doNotHide }),
replayDeleteMatch: (gameId: number) => ({ type: Types.REPLAY_DELETE_MATCH, gameId }), replayDeleteMatch: (gameId: number) => ({ type: Types.REPLAY_DELETE_MATCH, gameId }),
backendDecks: (deckList: Response_DeckList) => ({ type: Types.BACKEND_DECKS, deckList }), backendDecks: (deckList: Data.Response_DeckList) => ({ type: Types.BACKEND_DECKS, deckList }),
deckNewDir: (path: string, dirName: string) => ({ type: Types.DECK_NEW_DIR, path, dirName }), deckNewDir: (path: string, dirName: string) => ({ type: Types.DECK_NEW_DIR, path, dirName }),
deckDelDir: (path: string) => ({ type: Types.DECK_DEL_DIR, path }), deckDelDir: (path: string) => ({ type: Types.DECK_DEL_DIR, path }),
deckUpload: (path: string, treeItem: ServerInfo_DeckStorage_TreeItem) => ({ type: Types.DECK_UPLOAD, path, treeItem }), deckUpload: (path: string, treeItem: Data.ServerInfo_DeckStorage_TreeItem) => ({ type: Types.DECK_UPLOAD, path, treeItem }),
deckDelete: (deckId: number) => ({ type: Types.DECK_DELETE, deckId }), deckDelete: (deckId: number) => ({ type: Types.DECK_DELETE, deckId }),
gamesOfUser: (userName: string, games: ServerInfo_Game[], gametypeMap: GametypeMap) => gamesOfUser: (userName: string, response: Data.Response_GetGamesOfUser) =>
({ type: Types.GAMES_OF_USER, userName, games, gametypeMap }), ({ type: Types.GAMES_OF_USER, userName, response }),
} }
export type ServerAction = ReturnType<typeof Actions[keyof typeof Actions]>; export type ServerAction = ReturnType<typeof Actions[keyof typeof Actions]>;

View file

@ -1,26 +1,22 @@
vi.mock('store', () => ({ store: { dispatch: vi.fn() } })); vi.mock('..', () => ({ store: { dispatch: vi.fn() } }));
import { store } from 'store'; import { store } from '..';
import { Actions } from './server.actions'; import { Actions } from './server.actions';
import { Dispatch } from './server.dispatch'; import { Dispatch } from './server.dispatch';
import { App, Data } from '@app/types';
import { create } from '@bufbuild/protobuf'; import { create } from '@bufbuild/protobuf';
import { Event_NotifyUserSchema } from 'generated/proto/event_notify_user_pb';
import { Event_ServerShutdownSchema } from 'generated/proto/event_server_shutdown_pb';
import { Event_UserMessageSchema } from 'generated/proto/event_user_message_pb';
import { import {
makeBanHistoryItem, makeBanHistoryItem,
makeConnectOptions, makeLoginSuccessContext,
makePendingActivationContext,
makeDeckList, makeDeckList,
makeDeckTreeItem, makeDeckTreeItem,
makeGame,
makeReplayMatch, makeReplayMatch,
makeUser, makeUser,
makeWarnHistoryItem, makeWarnHistoryItem,
makeWarnListItem, makeWarnListItem,
} from './__mocks__/server-fixtures'; } from './__mocks__/server-fixtures';
beforeEach(() => vi.clearAllMocks());
describe('Dispatch', () => { describe('Dispatch', () => {
it('initialized dispatches Actions.initialized()', () => { it('initialized dispatches Actions.initialized()', () => {
Dispatch.initialized(); Dispatch.initialized();
@ -38,7 +34,7 @@ describe('Dispatch', () => {
}); });
it('loginSuccessful dispatches Actions.loginSuccessful()', () => { it('loginSuccessful dispatches Actions.loginSuccessful()', () => {
const options = makeConnectOptions(); const options = makeLoginSuccessContext();
Dispatch.loginSuccessful(options); Dispatch.loginSuccessful(options);
expect(store.dispatch).toHaveBeenCalledWith(Actions.loginSuccessful(options)); expect(store.dispatch).toHaveBeenCalledWith(Actions.loginSuccessful(options));
}); });
@ -48,11 +44,6 @@ describe('Dispatch', () => {
expect(store.dispatch).toHaveBeenCalledWith(Actions.loginFailed()); expect(store.dispatch).toHaveBeenCalledWith(Actions.loginFailed());
}); });
it('connectionClosed dispatches Actions.connectionClosed()', () => {
Dispatch.connectionClosed(3);
expect(store.dispatch).toHaveBeenCalledWith(Actions.connectionClosed(3));
});
it('connectionFailed dispatches Actions.connectionFailed()', () => { it('connectionFailed dispatches Actions.connectionFailed()', () => {
Dispatch.connectionFailed(); Dispatch.connectionFailed();
expect(store.dispatch).toHaveBeenCalledWith(Actions.connectionFailed()); expect(store.dispatch).toHaveBeenCalledWith(Actions.connectionFailed());
@ -108,8 +99,8 @@ describe('Dispatch', () => {
}); });
it('updateStatus dispatches Actions.updateStatus({ state, description })', () => { it('updateStatus dispatches Actions.updateStatus({ state, description })', () => {
Dispatch.updateStatus(2, 'ok'); Dispatch.updateStatus(App.StatusEnum.CONNECTED, 'ok');
expect(store.dispatch).toHaveBeenCalledWith(Actions.updateStatus({ state: 2, description: 'ok' })); expect(store.dispatch).toHaveBeenCalledWith(Actions.updateStatus({ state: App.StatusEnum.CONNECTED, description: 'ok' }));
}); });
it('updateUser dispatches Actions.updateUser()', () => { it('updateUser dispatches Actions.updateUser()', () => {
@ -136,7 +127,7 @@ describe('Dispatch', () => {
}); });
it('viewLogs dispatches Actions.viewLogs()', () => { it('viewLogs dispatches Actions.viewLogs()', () => {
const logs = [{ targetType: 'room' }] as any[]; const logs = [create(Data.ServerInfo_ChatMessageSchema, { targetType: 'room' })];
Dispatch.viewLogs(logs); Dispatch.viewLogs(logs);
expect(store.dispatch).toHaveBeenCalledWith(Actions.viewLogs(logs)); expect(store.dispatch).toHaveBeenCalledWith(Actions.viewLogs(logs));
}); });
@ -187,7 +178,7 @@ describe('Dispatch', () => {
}); });
it('accountAwaitingActivation dispatches correctly', () => { it('accountAwaitingActivation dispatches correctly', () => {
const options = makeConnectOptions(); const options = makePendingActivationContext();
Dispatch.accountAwaitingActivation(options); Dispatch.accountAwaitingActivation(options);
expect(store.dispatch).toHaveBeenCalledWith(Actions.accountAwaitingActivation(options)); expect(store.dispatch).toHaveBeenCalledWith(Actions.accountAwaitingActivation(options));
}); });
@ -266,19 +257,19 @@ describe('Dispatch', () => {
}); });
it('notifyUser dispatches correctly', () => { it('notifyUser dispatches correctly', () => {
const notification = create(Event_NotifyUserSchema, { type: 1, warningReason: '', customTitle: '', customContent: '' }); const notification = create(Data.Event_NotifyUserSchema, { type: 1, warningReason: '', customTitle: '', customContent: '' });
Dispatch.notifyUser(notification); Dispatch.notifyUser(notification);
expect(store.dispatch).toHaveBeenCalledWith(Actions.notifyUser(notification)); expect(store.dispatch).toHaveBeenCalledWith(Actions.notifyUser(notification));
}); });
it('serverShutdown dispatches correctly', () => { it('serverShutdown dispatches correctly', () => {
const data = create(Event_ServerShutdownSchema, { reason: 'maintenance', minutes: 5 }); const data = create(Data.Event_ServerShutdownSchema, { reason: 'maintenance', minutes: 5 });
Dispatch.serverShutdown(data); Dispatch.serverShutdown(data);
expect(store.dispatch).toHaveBeenCalledWith(Actions.serverShutdown(data)); expect(store.dispatch).toHaveBeenCalledWith(Actions.serverShutdown(data));
}); });
it('userMessage dispatches correctly', () => { it('userMessage dispatches correctly', () => {
const messageData = create(Event_UserMessageSchema, { senderName: 'Alice', receiverName: 'Bob', message: 'hey' }); const messageData = create(Data.Event_UserMessageSchema, { senderName: 'Alice', receiverName: 'Bob', message: 'hey' });
Dispatch.userMessage(messageData); Dispatch.userMessage(messageData);
expect(store.dispatch).toHaveBeenCalledWith(Actions.userMessage(messageData)); expect(store.dispatch).toHaveBeenCalledWith(Actions.userMessage(messageData));
}); });
@ -391,10 +382,9 @@ describe('Dispatch', () => {
}); });
it('gamesOfUser dispatches correctly', () => { it('gamesOfUser dispatches correctly', () => {
const games = [makeGame({ gameId: 1 })]; const response = create(Data.Response_GetGamesOfUserSchema, { roomList: [], gameList: [] });
const gametypeMap = { 1: 'Standard' }; Dispatch.gamesOfUser('alice', response);
Dispatch.gamesOfUser('alice', games, gametypeMap); expect(store.dispatch).toHaveBeenCalledWith(Actions.gamesOfUser('alice', response));
expect(store.dispatch).toHaveBeenCalledWith(Actions.gamesOfUser('alice', games, gametypeMap));
}); });
it('clearRegistrationErrors dispatches correctly', () => { it('clearRegistrationErrors dispatches correctly', () => {

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