refactor redux data model

This commit is contained in:
seavor 2026-04-15 21:48:03 -05:00
parent ae1bc3da38
commit 0ff391491d
243 changed files with 5212 additions and 5963 deletions

View file

@ -0,0 +1,55 @@
import boundaries from 'eslint-plugin-boundaries';
const elements = [
{ type: 'api', pattern: ['src/api/**'] },
{ type: 'components', pattern: ['src/components/**'] },
{ type: 'containers', pattern: ['src/containers/**'] },
{ type: 'dialogs', pattern: ['src/dialogs/**'] },
{ type: 'forms', pattern: ['src/forms/**'] },
{ type: 'generated', pattern: ['src/generated/**'] },
{ type: 'hooks', pattern: ['src/hooks/**'] },
{ type: 'images', pattern: ['src/images/**'] },
{ type: 'services', pattern: ['src/services/**'] },
{ type: 'store', pattern: ['src/store/**'] },
{ type: 'types', pattern: ['src/types/**'] },
{ type: 'websocket', pattern: ['src/websocket/**'] },
];
const types = (...types) => types.map((type) => ({ to: { type } }));
const rules = [
{ from: { type: 'generated' }, allow: [] },
{ from: { type: 'types' }, allow: types('generated') },
{ from: { type: 'websocket' }, allow: types('types') },
{ from: { type: 'store' }, allow: types('types') },
{ from: { type: 'api' }, allow: types('types', 'store', 'websocket') },
{ from: { type: 'hooks' }, allow: types('services', 'types') },
{ from: { type: 'images' }, allow: types('types') },
{ from: { type: 'services' }, allow: types('api', 'store', 'types') },
{ from: { type: 'components' }, allow: types('api', 'dialogs', 'forms', 'hooks', 'images', 'services', 'types', 'store') },
{ from: { type: 'containers' }, allow: types('api', 'components', 'dialogs', 'forms', 'hooks', 'images', 'services', 'types', 'store') },
{ from: { type: 'dialogs' }, allow: types('components', 'forms', 'hooks', 'services', 'types', 'store') },
{ from: { type: 'forms' }, allow: types('components', 'hooks', 'types', 'services', 'store') },
];
export const boundariesConfig = {
plugins: { boundaries },
settings: {
'boundaries/elements': elements,
'import/resolver': {
typescript: {
alwaysTryTypes: true,
project: './tsconfig.json',
},
},
},
rules: {
'boundaries/dependencies': ['error', {
default: 'disallow',
rules,
}],
},
};

View file

@ -1,6 +1,7 @@
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
import globals from 'globals';
import { boundariesConfig } from './eslint.boundaries.mjs';
export default tseslint.config(
// Global ignores
@ -12,6 +13,9 @@ export default tseslint.config(
// TypeScript recommended (sets up parser + plugin)
...tseslint.configs.recommended,
// Enforce module boundaries
boundariesConfig,
// Project-specific config
{
languageOptions: {

View file

@ -23,7 +23,6 @@
"i18next-browser-languagedetector": "^8.2.1",
"i18next-icu": "^2.0.3",
"intl-messageformat": "^11.2.1",
"lodash": "^4.17.21",
"prop-types": "^15.8.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",
@ -45,7 +44,6 @@
"@testing-library/jest-dom": "^6.4.0",
"@testing-library/react": "^16.3.2",
"@types/dompurify": "^3.0.5",
"@types/lodash": "^4.14.179",
"@types/node": "^22.19.17",
"@types/prop-types": "^15.7.4",
"@types/react": "^19.0.0",
@ -57,6 +55,8 @@
"@vitejs/plugin-react": "^6.0.1",
"@vitest/coverage-v8": "^4.1.4",
"eslint": "^10.2.0",
"eslint-import-resolver-typescript": "^4.4.4",
"eslint-plugin-boundaries": "^6.0.2",
"fs-extra": "^11.3.4",
"globals": "^17.5.0",
"husky": "^9.1.7",
@ -262,6 +262,23 @@
"node": ">=18"
}
},
"node_modules/@boundaries/elements": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@boundaries/elements/-/elements-2.0.1.tgz",
"integrity": "sha512-sAWO3D8PFP6pBXdxxW93SQi/KQqqhE2AAHo3AgWfdtJXwO6bfK6/wUN81XnOZk0qRC6vHzUEKhjwVD9dtDWvxg==",
"dev": true,
"license": "MIT",
"dependencies": {
"eslint-import-resolver-node": "0.3.9",
"eslint-module-utils": "2.12.1",
"handlebars": "4.7.9",
"is-core-module": "2.16.1",
"micromatch": "4.0.8"
},
"engines": {
"node": ">=18.18"
}
},
"node_modules/@bramus/specificity": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
@ -1753,13 +1770,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/lodash": {
"version": "4.17.24",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz",
"integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
"version": "22.19.17",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz",
@ -2090,6 +2100,288 @@
"typescript": "*"
}
},
"node_modules/@unrs/resolver-binding-android-arm-eabi": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz",
"integrity": "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
]
},
"node_modules/@unrs/resolver-binding-android-arm64": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz",
"integrity": "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
]
},
"node_modules/@unrs/resolver-binding-darwin-arm64": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz",
"integrity": "sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@unrs/resolver-binding-darwin-x64": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz",
"integrity": "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@unrs/resolver-binding-freebsd-x64": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz",
"integrity": "sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
]
},
"node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz",
"integrity": "sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@unrs/resolver-binding-linux-arm-musleabihf": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz",
"integrity": "sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@unrs/resolver-binding-linux-arm64-gnu": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz",
"integrity": "sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@unrs/resolver-binding-linux-arm64-musl": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz",
"integrity": "sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@unrs/resolver-binding-linux-ppc64-gnu": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz",
"integrity": "sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@unrs/resolver-binding-linux-riscv64-gnu": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz",
"integrity": "sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@unrs/resolver-binding-linux-riscv64-musl": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz",
"integrity": "sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@unrs/resolver-binding-linux-s390x-gnu": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz",
"integrity": "sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@unrs/resolver-binding-linux-x64-gnu": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz",
"integrity": "sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@unrs/resolver-binding-linux-x64-musl": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.1.tgz",
"integrity": "sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@unrs/resolver-binding-wasm32-wasi": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz",
"integrity": "sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==",
"cpu": [
"wasm32"
],
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@napi-rs/wasm-runtime": "^0.2.11"
},
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
"version": "0.2.12",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz",
"integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "^1.4.3",
"@emnapi/runtime": "^1.4.3",
"@tybys/wasm-util": "^0.10.0"
}
},
"node_modules/@unrs/resolver-binding-win32-arm64-msvc": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz",
"integrity": "sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@unrs/resolver-binding-win32-ia32-msvc": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz",
"integrity": "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@unrs/resolver-binding-win32-x64-msvc": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz",
"integrity": "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@vitejs/plugin-react": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz",
@ -2417,6 +2709,19 @@
"node": "18 || 20 || >=22"
}
},
"node_modules/braces": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
"dev": true,
"license": "MIT",
"dependencies": {
"fill-range": "^7.1.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/callsites": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
@ -2436,6 +2741,39 @@
"node": ">=18"
}
},
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.1.0",
"supports-color": "^7.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/chalk/node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
@ -2445,6 +2783,26 @@
"node": ">=6"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
},
"engines": {
"node": ">=7.0.0"
}
},
"node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"license": "MIT"
},
"node_modules/convert-source-map": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz",
@ -2731,6 +3089,137 @@
}
}
},
"node_modules/eslint-import-context": {
"version": "0.1.9",
"resolved": "https://registry.npmjs.org/eslint-import-context/-/eslint-import-context-0.1.9.tgz",
"integrity": "sha512-K9Hb+yRaGAGUbwjhFNHvSmmkZs9+zbuoe3kFQ4V1wYjrepUFYM2dZAfNtjbbj3qsPfUfsA68Bx/ICWQMi+C8Eg==",
"dev": true,
"license": "MIT",
"dependencies": {
"get-tsconfig": "^4.10.1",
"stable-hash-x": "^0.2.0"
},
"engines": {
"node": "^12.20.0 || ^14.18.0 || >=16.0.0"
},
"funding": {
"url": "https://opencollective.com/eslint-import-context"
},
"peerDependencies": {
"unrs-resolver": "^1.0.0"
},
"peerDependenciesMeta": {
"unrs-resolver": {
"optional": true
}
}
},
"node_modules/eslint-import-resolver-node": {
"version": "0.3.9",
"resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz",
"integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==",
"dev": true,
"license": "MIT",
"dependencies": {
"debug": "^3.2.7",
"is-core-module": "^2.13.0",
"resolve": "^1.22.4"
}
},
"node_modules/eslint-import-resolver-node/node_modules/debug": {
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
"integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.1"
}
},
"node_modules/eslint-import-resolver-typescript": {
"version": "4.4.4",
"resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-4.4.4.tgz",
"integrity": "sha512-1iM2zeBvrYmUNTj2vSC/90JTHDth+dfOfiNKkxApWRsTJYNrc8rOdxxIf5vazX+BiAXTeOT0UvWpGI/7qIWQOw==",
"dev": true,
"license": "ISC",
"dependencies": {
"debug": "^4.4.1",
"eslint-import-context": "^0.1.8",
"get-tsconfig": "^4.10.1",
"is-bun-module": "^2.0.0",
"stable-hash-x": "^0.2.0",
"tinyglobby": "^0.2.14",
"unrs-resolver": "^1.7.11"
},
"engines": {
"node": "^16.17.0 || >=18.6.0"
},
"funding": {
"url": "https://opencollective.com/eslint-import-resolver-typescript"
},
"peerDependencies": {
"eslint": "*",
"eslint-plugin-import": "*",
"eslint-plugin-import-x": "*"
},
"peerDependenciesMeta": {
"eslint-plugin-import": {
"optional": true
},
"eslint-plugin-import-x": {
"optional": true
}
}
},
"node_modules/eslint-module-utils": {
"version": "2.12.1",
"resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz",
"integrity": "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==",
"dev": true,
"license": "MIT",
"dependencies": {
"debug": "^3.2.7"
},
"engines": {
"node": ">=4"
},
"peerDependenciesMeta": {
"eslint": {
"optional": true
}
}
},
"node_modules/eslint-module-utils/node_modules/debug": {
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
"integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.1"
}
},
"node_modules/eslint-plugin-boundaries": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/eslint-plugin-boundaries/-/eslint-plugin-boundaries-6.0.2.tgz",
"integrity": "sha512-wSHgiYeMEbziP91lH0UQ9oslgF2djG1x+LV9z/qO19ggMKZaCB8pKIGePHAY91eLF4EAgpsxQk8MRSFGRPfPzw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@boundaries/elements": "2.0.1",
"chalk": "4.1.2",
"eslint-import-resolver-node": "0.3.9",
"eslint-module-utils": "2.12.1",
"handlebars": "4.7.9",
"micromatch": "4.0.8"
},
"engines": {
"node": ">=18.18"
},
"peerDependencies": {
"eslint": ">=6.0.0"
}
},
"node_modules/eslint-scope": {
"version": "9.1.2",
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz",
@ -2935,6 +3424,19 @@
"node": ">=16.0.0"
}
},
"node_modules/fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
"dev": true,
"license": "MIT",
"dependencies": {
"to-regex-range": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/final-form": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/final-form/-/final-form-5.0.0.tgz",
@ -3043,6 +3545,19 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-tsconfig": {
"version": "4.14.0",
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz",
"integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==",
"dev": true,
"license": "MIT",
"dependencies": {
"resolve-pkg-maps": "^1.0.0"
},
"funding": {
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
}
},
"node_modules/glob-parent": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
@ -3076,6 +3591,38 @@
"dev": true,
"license": "ISC"
},
"node_modules/handlebars": {
"version": "4.7.9",
"resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz",
"integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"minimist": "^1.2.5",
"neo-async": "^2.6.2",
"source-map": "^0.6.1",
"wordwrap": "^1.0.0"
},
"bin": {
"handlebars": "bin/handlebars"
},
"engines": {
"node": ">=0.4.7"
},
"optionalDependencies": {
"uglify-js": "^3.1.4"
}
},
"node_modules/handlebars/node_modules/source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
@ -3279,6 +3826,16 @@
"integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==",
"license": "MIT"
},
"node_modules/is-bun-module": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/is-bun-module/-/is-bun-module-2.0.0.tgz",
"integrity": "sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"semver": "^7.7.1"
}
},
"node_modules/is-core-module": {
"version": "2.16.1",
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
@ -3317,6 +3874,16 @@
"node": ">=0.10.0"
}
},
"node_modules/is-number": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.12.0"
}
},
"node_modules/is-potential-custom-element-name": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
@ -3776,12 +4343,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/lodash": {
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"license": "MIT"
},
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
@ -3859,6 +4420,33 @@
"dev": true,
"license": "CC0-1.0"
},
"node_modules/micromatch": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
"dev": true,
"license": "MIT",
"dependencies": {
"braces": "^3.0.3",
"picomatch": "^2.3.1"
},
"engines": {
"node": ">=8.6"
}
},
"node_modules/micromatch/node_modules/picomatch": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8.6"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/min-indent": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
@ -3885,6 +4473,16 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@ -3910,6 +4508,22 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/napi-postinstall": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz",
"integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==",
"dev": true,
"license": "MIT",
"bin": {
"napi-postinstall": "lib/cli.js"
},
"engines": {
"node": "^12.20.0 || ^14.18.0 || >=16.0.0"
},
"funding": {
"url": "https://opencollective.com/napi-postinstall"
}
},
"node_modules/natural-compare": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
@ -3917,6 +4531,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/neo-async": {
"version": "2.6.2",
"resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz",
"integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==",
"dev": true,
"license": "MIT"
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@ -4436,6 +5057,16 @@
"node": ">=4"
}
},
"node_modules/resolve-pkg-maps": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
}
},
"node_modules/rolldown": {
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.15.tgz",
@ -4573,6 +5204,16 @@
"node": ">=0.10.0"
}
},
"node_modules/stable-hash-x": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/stable-hash-x/-/stable-hash-x-0.2.0.tgz",
"integrity": "sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/stackback": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
@ -4702,6 +5343,19 @@
"dev": true,
"license": "MIT"
},
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-number": "^7.0.0"
},
"engines": {
"node": ">=8.0"
}
},
"node_modules/tough-cookie": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz",
@ -4798,6 +5452,20 @@
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/uglify-js": {
"version": "3.19.3",
"resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz",
"integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==",
"dev": true,
"license": "BSD-2-Clause",
"optional": true,
"bin": {
"uglifyjs": "bin/uglifyjs"
},
"engines": {
"node": ">=0.8.0"
}
},
"node_modules/undici": {
"version": "7.25.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz",
@ -4825,6 +5493,41 @@
"node": ">= 10.0.0"
}
},
"node_modules/unrs-resolver": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz",
"integrity": "sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"napi-postinstall": "^0.3.0"
},
"funding": {
"url": "https://opencollective.com/unrs-resolver"
},
"optionalDependencies": {
"@unrs/resolver-binding-android-arm-eabi": "1.11.1",
"@unrs/resolver-binding-android-arm64": "1.11.1",
"@unrs/resolver-binding-darwin-arm64": "1.11.1",
"@unrs/resolver-binding-darwin-x64": "1.11.1",
"@unrs/resolver-binding-freebsd-x64": "1.11.1",
"@unrs/resolver-binding-linux-arm-gnueabihf": "1.11.1",
"@unrs/resolver-binding-linux-arm-musleabihf": "1.11.1",
"@unrs/resolver-binding-linux-arm64-gnu": "1.11.1",
"@unrs/resolver-binding-linux-arm64-musl": "1.11.1",
"@unrs/resolver-binding-linux-ppc64-gnu": "1.11.1",
"@unrs/resolver-binding-linux-riscv64-gnu": "1.11.1",
"@unrs/resolver-binding-linux-riscv64-musl": "1.11.1",
"@unrs/resolver-binding-linux-s390x-gnu": "1.11.1",
"@unrs/resolver-binding-linux-x64-gnu": "1.11.1",
"@unrs/resolver-binding-linux-x64-musl": "1.11.1",
"@unrs/resolver-binding-wasm32-wasi": "1.11.1",
"@unrs/resolver-binding-win32-arm64-msvc": "1.11.1",
"@unrs/resolver-binding-win32-ia32-msvc": "1.11.1",
"@unrs/resolver-binding-win32-x64-msvc": "1.11.1"
}
},
"node_modules/uri-js": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
@ -5112,6 +5815,13 @@
"node": ">=0.10.0"
}
},
"node_modules/wordwrap": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz",
"integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==",
"dev": true,
"license": "MIT"
},
"node_modules/xml-name-validator": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",

View file

@ -33,7 +33,6 @@
"i18next-browser-languagedetector": "^8.2.1",
"i18next-icu": "^2.0.3",
"intl-messageformat": "^11.2.1",
"lodash": "^4.17.21",
"prop-types": "^15.8.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",
@ -55,7 +54,6 @@
"@testing-library/jest-dom": "^6.4.0",
"@testing-library/react": "^16.3.2",
"@types/dompurify": "^3.0.5",
"@types/lodash": "^4.14.179",
"@types/node": "^22.19.17",
"@types/prop-types": "^15.7.4",
"@types/react": "^19.0.0",
@ -67,6 +65,8 @@
"@vitejs/plugin-react": "^6.0.1",
"@vitest/coverage-v8": "^4.1.4",
"eslint": "^10.2.0",
"eslint-import-resolver-typescript": "^4.4.4",
"eslint-plugin-boundaries": "^6.0.2",
"fs-extra": "^11.3.4",
"globals": "^17.5.0",
"husky": "^9.1.7",

View file

@ -1,46 +0,0 @@
vi.mock('@app/websocket', () => ({
AdminCommands: {
adjustMod: vi.fn(),
reloadConfig: vi.fn(),
shutdownServer: vi.fn(),
updateServerMessage: vi.fn(),
},
}));
import { AdminService } from './AdminService';
import { AdminCommands } from '@app/websocket';
describe('AdminService', () => {
describe('adjustMod', () => {
it('delegates to AdminCommands.adjustMod with all arguments', () => {
AdminService.adjustMod('alice', true, false);
expect(AdminCommands.adjustMod).toHaveBeenCalledWith('alice', true, false);
});
it('delegates with optional arguments omitted', () => {
AdminService.adjustMod('alice');
expect(AdminCommands.adjustMod).toHaveBeenCalledWith('alice', undefined, undefined);
});
});
describe('reloadConfig', () => {
it('delegates to AdminCommands.reloadConfig', () => {
AdminService.reloadConfig();
expect(AdminCommands.reloadConfig).toHaveBeenCalled();
});
});
describe('shutdownServer', () => {
it('delegates to AdminCommands.shutdownServer', () => {
AdminService.shutdownServer('maintenance', 10);
expect(AdminCommands.shutdownServer).toHaveBeenCalledWith('maintenance', 10);
});
});
describe('updateServerMessage', () => {
it('delegates to AdminCommands.updateServerMessage', () => {
AdminService.updateServerMessage();
expect(AdminCommands.updateServerMessage).toHaveBeenCalled();
});
});
});

View file

@ -1,19 +0,0 @@
import { AdminCommands } from '@app/websocket';
export class AdminService {
static adjustMod(userName: string, shouldBeMod?: boolean, shouldBeJudge?: boolean): void {
AdminCommands.adjustMod(userName, shouldBeMod, shouldBeJudge);
}
static reloadConfig(): void {
AdminCommands.reloadConfig();
}
static shutdownServer(reason: string, minutes: number): void {
AdminCommands.shutdownServer(reason, minutes);
}
static updateServerMessage(): void {
AdminCommands.updateServerMessage();
}
}

View file

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

View file

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

View file

@ -1,73 +0,0 @@
vi.mock('@app/websocket', () => ({
ModeratorCommands: {
banFromServer: vi.fn(),
getBanHistory: vi.fn(),
getWarnHistory: vi.fn(),
getWarnList: vi.fn(),
viewLogHistory: vi.fn(),
warnUser: vi.fn(),
},
}));
import { ModeratorService } from './ModeratorService';
import { ModeratorCommands } from '@app/websocket';
import { Data } from '@app/types';
describe('ModeratorService', () => {
describe('banFromServer', () => {
it('delegates to ModeratorCommands.banFromServer with all arguments', () => {
ModeratorService.banFromServer(30, 'alice', '1.2.3.4', 'reason', 'visible reason', 'cid', 1);
expect(ModeratorCommands.banFromServer).toHaveBeenCalledWith(
30, 'alice', '1.2.3.4', 'reason', 'visible reason', 'cid', 1
);
});
it('delegates with only required argument', () => {
ModeratorService.banFromServer(60);
expect(ModeratorCommands.banFromServer).toHaveBeenCalledWith(
60, undefined, undefined, undefined, undefined, undefined, undefined
);
});
});
describe('getBanHistory', () => {
it('delegates to ModeratorCommands.getBanHistory', () => {
ModeratorService.getBanHistory('alice');
expect(ModeratorCommands.getBanHistory).toHaveBeenCalledWith('alice');
});
});
describe('getWarnHistory', () => {
it('delegates to ModeratorCommands.getWarnHistory', () => {
ModeratorService.getWarnHistory('alice');
expect(ModeratorCommands.getWarnHistory).toHaveBeenCalledWith('alice');
});
});
describe('getWarnList', () => {
it('delegates to ModeratorCommands.getWarnList', () => {
ModeratorService.getWarnList('mod1', 'alice', 'cid123');
expect(ModeratorCommands.getWarnList).toHaveBeenCalledWith('mod1', 'alice', 'cid123');
});
});
describe('viewLogHistory', () => {
it('delegates to ModeratorCommands.viewLogHistory', () => {
const filters: Data.ViewLogHistoryParams = { dateRange: 7, userName: 'alice' };
ModeratorService.viewLogHistory(filters);
expect(ModeratorCommands.viewLogHistory).toHaveBeenCalledWith(filters);
});
});
describe('warnUser', () => {
it('delegates to ModeratorCommands.warnUser with all arguments', () => {
ModeratorService.warnUser('alice', 'spamming', 'cid', 5);
expect(ModeratorCommands.warnUser).toHaveBeenCalledWith('alice', 'spamming', 'cid', 5);
});
it('delegates with only required arguments', () => {
ModeratorService.warnUser('alice', 'spamming');
expect(ModeratorCommands.warnUser).toHaveBeenCalledWith('alice', 'spamming', undefined, undefined);
});
});
});

View file

@ -1,29 +0,0 @@
import { ModeratorCommands } from '@app/websocket';
import { Data } from '@app/types';
export class ModeratorService {
static banFromServer(minutes: number, userName?: string, address?: string, reason?: string,
visibleReason?: string, clientid?: string, removeMessages?: number): void {
ModeratorCommands.banFromServer(minutes, userName, address, reason, visibleReason, clientid, removeMessages);
}
static getBanHistory(userName: string): void {
ModeratorCommands.getBanHistory(userName);
}
static getWarnHistory(userName: string): void {
ModeratorCommands.getWarnHistory(userName);
}
static getWarnList(modName: string, userName: string, userClientid: string): void {
ModeratorCommands.getWarnList(modName, userName, userClientid);
}
static viewLogHistory(filters: Data.ViewLogHistoryParams): void {
ModeratorCommands.viewLogHistory(filters);
}
static warnUser(userName: string, reason: string, clientid?: string, removeMessages?: number): void {
ModeratorCommands.warnUser(userName, reason, clientid, removeMessages);
}
}

View file

@ -1,35 +0,0 @@
vi.mock('@app/websocket', () => ({
SessionCommands: {
joinRoom: vi.fn(),
},
RoomCommands: {
leaveRoom: vi.fn(),
roomSay: vi.fn(),
},
}));
import { RoomsService } from './RoomsService';
import { RoomCommands, SessionCommands } from '@app/websocket';
describe('RoomsService', () => {
describe('joinRoom', () => {
it('delegates to SessionCommands.joinRoom', () => {
RoomsService.joinRoom(42);
expect(SessionCommands.joinRoom).toHaveBeenCalledWith(42);
});
});
describe('leaveRoom', () => {
it('delegates to RoomCommands.leaveRoom', () => {
RoomsService.leaveRoom(42);
expect(RoomCommands.leaveRoom).toHaveBeenCalledWith(42);
});
});
describe('roomSay', () => {
it('delegates to RoomCommands.roomSay', () => {
RoomsService.roomSay(42, 'hello room');
expect(RoomCommands.roomSay).toHaveBeenCalledWith(42, 'hello room');
});
});
});

View file

@ -1,15 +0,0 @@
import { RoomCommands, SessionCommands } from '@app/websocket';
export class RoomsService {
static joinRoom(roomId: number): void {
SessionCommands.joinRoom(roomId);
}
static leaveRoom(roomId: number): void {
RoomCommands.leaveRoom(roomId);
}
static roomSay(roomId: number, message: string): void {
RoomCommands.roomSay(roomId, message);
}
}

View file

@ -1,100 +0,0 @@
vi.mock('@app/websocket', () => ({
SessionCommands: {
addToBuddyList: vi.fn(),
removeFromBuddyList: vi.fn(),
addToIgnoreList: vi.fn(),
removeFromIgnoreList: vi.fn(),
accountPassword: vi.fn(),
accountEdit: vi.fn(),
accountImage: vi.fn(),
message: vi.fn(),
getUserInfo: vi.fn(),
getGamesOfUser: vi.fn(),
},
}));
import { SessionService } from './SessionService';
import { SessionCommands } from '@app/websocket';
describe('SessionService', () => {
describe('addToBuddyList', () => {
it('delegates to SessionCommands.addToBuddyList', () => {
SessionService.addToBuddyList('alice');
expect(SessionCommands.addToBuddyList).toHaveBeenCalledWith('alice');
});
});
describe('removeFromBuddyList', () => {
it('delegates to SessionCommands.removeFromBuddyList', () => {
SessionService.removeFromBuddyList('alice');
expect(SessionCommands.removeFromBuddyList).toHaveBeenCalledWith('alice');
});
});
describe('addToIgnoreList', () => {
it('delegates to SessionCommands.addToIgnoreList', () => {
SessionService.addToIgnoreList('bob');
expect(SessionCommands.addToIgnoreList).toHaveBeenCalledWith('bob');
});
});
describe('removeFromIgnoreList', () => {
it('delegates to SessionCommands.removeFromIgnoreList', () => {
SessionService.removeFromIgnoreList('bob');
expect(SessionCommands.removeFromIgnoreList).toHaveBeenCalledWith('bob');
});
});
describe('changeAccountPassword', () => {
it('delegates to SessionCommands.accountPassword with all arguments', () => {
SessionService.changeAccountPassword('oldPw', 'newPw', 'hashedPw');
expect(SessionCommands.accountPassword).toHaveBeenCalledWith('oldPw', 'newPw', 'hashedPw');
});
it('delegates without hashedNewPassword when omitted', () => {
SessionService.changeAccountPassword('oldPw', 'newPw');
expect(SessionCommands.accountPassword).toHaveBeenCalledWith('oldPw', 'newPw', undefined);
});
});
describe('changeAccountDetails', () => {
it('delegates to SessionCommands.accountEdit with all arguments', () => {
SessionService.changeAccountDetails('pw', 'Alice', 'alice@example.com', 'US');
expect(SessionCommands.accountEdit).toHaveBeenCalledWith('pw', 'Alice', 'alice@example.com', 'US');
});
it('delegates with only required argument', () => {
SessionService.changeAccountDetails('pw');
expect(SessionCommands.accountEdit).toHaveBeenCalledWith('pw', undefined, undefined, undefined);
});
});
describe('changeAccountImage', () => {
it('delegates to SessionCommands.accountImage', () => {
const image = new Uint8Array([1, 2, 3]);
SessionService.changeAccountImage(image);
expect(SessionCommands.accountImage).toHaveBeenCalledWith(image);
});
});
describe('sendDirectMessage', () => {
it('delegates to SessionCommands.message', () => {
SessionService.sendDirectMessage('alice', 'hello');
expect(SessionCommands.message).toHaveBeenCalledWith('alice', 'hello');
});
});
describe('getUserInfo', () => {
it('delegates to SessionCommands.getUserInfo', () => {
SessionService.getUserInfo('alice');
expect(SessionCommands.getUserInfo).toHaveBeenCalledWith('alice');
});
});
describe('getUserGames', () => {
it('delegates to SessionCommands.getGamesOfUser', () => {
SessionService.getUserGames('alice');
expect(SessionCommands.getGamesOfUser).toHaveBeenCalledWith('alice');
});
});
});

View file

@ -1,43 +0,0 @@
import { SessionCommands } from '@app/websocket';
export class SessionService {
static addToBuddyList(userName: string) {
SessionCommands.addToBuddyList(userName);
}
static removeFromBuddyList(userName: string) {
SessionCommands.removeFromBuddyList(userName);
}
static addToIgnoreList(userName: string) {
SessionCommands.addToIgnoreList(userName);
}
static removeFromIgnoreList(userName: string) {
SessionCommands.removeFromIgnoreList(userName);
}
static changeAccountPassword(oldPassword: string, newPassword: string, hashedNewPassword?: string): void {
SessionCommands.accountPassword(oldPassword, newPassword, hashedNewPassword);
}
static changeAccountDetails(passwordCheck: string, realName?: string, email?: string, country?: string): void {
SessionCommands.accountEdit(passwordCheck, realName, email, country);
}
static changeAccountImage(image: Uint8Array): void {
SessionCommands.accountImage(image);
}
static sendDirectMessage(userName: string, message: string): void {
SessionCommands.message(userName, message);
}
static getUserInfo(userName: string): void {
SessionCommands.getUserInfo(userName);
}
static getUserGames(userName: string): void {
SessionCommands.getGamesOfUser(userName);
}
}

View file

@ -1,5 +1,32 @@
export { AdminService } from './AdminService';
export { AuthenticationService } from './AuthenticationService';
export { ModeratorService } from './ModeratorService';
export { RoomsService } from './RoomsService';
export { SessionService } from './SessionService';
import { WebClient } from '@app/websocket';
import type { IWebClientRequest } from '@app/websocket';
export { createWebClientResponse } from './response';
export { createWebClientRequest } from './request';
/**
* UI-facing request surface. Each property is a lazy getter that resolves
* `WebClient.instance` at call time, so consumers can import this before the
* singleton is bootstrapped it only needs to exist by the first actual call.
*
* Prefer this over importing `WebClient` directly: it keeps UI code free of
* transport-layer names and makes `@app/websocket` an internal detail of the
* `api` layer.
*/
export const request: IWebClientRequest = {
get authentication() {
return WebClient.instance.request.authentication;
},
get session() {
return WebClient.instance.request.session;
},
get rooms() {
return WebClient.instance.request.rooms;
},
get admin() {
return WebClient.instance.request.admin;
},
get moderator() {
return WebClient.instance.request.moderator;
},
};

View file

@ -0,0 +1,20 @@
import type { IAdminRequest } from '@app/websocket';
import { AdminCommands } from '@app/websocket';
export class AdminRequestImpl implements IAdminRequest {
adjustMod(userName: string, shouldBeMod?: boolean, shouldBeJudge?: boolean): void {
AdminCommands.adjustMod(userName, shouldBeMod, shouldBeJudge);
}
reloadConfig(): void {
AdminCommands.reloadConfig();
}
shutdownServer(reason: string, minutes: number): void {
AdminCommands.shutdownServer(reason, minutes);
}
updateServerMessage(): void {
AdminCommands.updateServerMessage();
}
}

View file

@ -0,0 +1,37 @@
import { App, Enriched } from '@app/types';
import type { IAuthenticationRequest } from '@app/websocket';
import { SessionCommands } from '@app/websocket';
export class AuthenticationRequestImpl implements IAuthenticationRequest {
login(options: Omit<Enriched.LoginConnectOptions, 'reason'>): void {
SessionCommands.connect({ ...options, reason: App.WebSocketConnectReason.LOGIN });
}
testConnection(options: Omit<Enriched.TestConnectionOptions, 'reason'>): void {
SessionCommands.connect({ ...options, reason: App.WebSocketConnectReason.TEST_CONNECTION });
}
register(options: Omit<Enriched.RegisterConnectOptions, 'reason'>): void {
SessionCommands.connect({ ...options, reason: App.WebSocketConnectReason.REGISTER });
}
activateAccount(options: Omit<Enriched.ActivateConnectOptions, 'reason'>): void {
SessionCommands.connect({ ...options, reason: App.WebSocketConnectReason.ACTIVATE_ACCOUNT });
}
resetPasswordRequest(options: Omit<Enriched.PasswordResetRequestConnectOptions, 'reason'>): void {
SessionCommands.connect({ ...options, reason: App.WebSocketConnectReason.PASSWORD_RESET_REQUEST });
}
resetPasswordChallenge(options: Omit<Enriched.PasswordResetChallengeConnectOptions, 'reason'>): void {
SessionCommands.connect({ ...options, reason: App.WebSocketConnectReason.PASSWORD_RESET_CHALLENGE });
}
resetPassword(options: Omit<Enriched.PasswordResetConnectOptions, 'reason'>): void {
SessionCommands.connect({ ...options, reason: App.WebSocketConnectReason.PASSWORD_RESET });
}
disconnect(): void {
SessionCommands.disconnect();
}
}

View file

@ -0,0 +1,37 @@
import { Data } from '@app/types';
import type { IModeratorRequest } from '@app/websocket';
import { ModeratorCommands } from '@app/websocket';
export class ModeratorRequestImpl implements IModeratorRequest {
banFromServer(
minutes: number,
userName?: string,
address?: string,
reason?: string,
visibleReason?: string,
clientid?: string,
removeMessages?: number
): void {
ModeratorCommands.banFromServer(minutes, userName, address, reason, visibleReason, clientid, removeMessages);
}
getBanHistory(userName: string): void {
ModeratorCommands.getBanHistory(userName);
}
getWarnHistory(userName: string): void {
ModeratorCommands.getWarnHistory(userName);
}
getWarnList(modName: string, userName: string, userClientid: string): void {
ModeratorCommands.getWarnList(modName, userName, userClientid);
}
viewLogHistory(filters: Data.ViewLogHistoryParams): void {
ModeratorCommands.viewLogHistory(filters);
}
warnUser(userName: string, reason: string, clientid?: string, removeMessages?: number): void {
ModeratorCommands.warnUser(userName, reason, clientid, removeMessages);
}
}

View file

@ -0,0 +1,16 @@
import type { IRoomsRequest } from '@app/websocket';
import { RoomCommands, SessionCommands } from '@app/websocket';
export class RoomsRequestImpl implements IRoomsRequest {
joinRoom(roomId: number): void {
SessionCommands.joinRoom(roomId);
}
leaveRoom(roomId: number): void {
RoomCommands.leaveRoom(roomId);
}
roomSay(roomId: number, message: string): void {
RoomCommands.roomSay(roomId, message);
}
}

View file

@ -0,0 +1,44 @@
import type { ISessionRequest } from '@app/websocket';
import { SessionCommands } from '@app/websocket';
export class SessionRequestImpl implements ISessionRequest {
addToBuddyList(userName: string): void {
SessionCommands.addToBuddyList(userName);
}
removeFromBuddyList(userName: string): void {
SessionCommands.removeFromBuddyList(userName);
}
addToIgnoreList(userName: string): void {
SessionCommands.addToIgnoreList(userName);
}
removeFromIgnoreList(userName: string): void {
SessionCommands.removeFromIgnoreList(userName);
}
changeAccountPassword(oldPassword: string, newPassword: string, hashedNewPassword?: string): void {
SessionCommands.accountPassword(oldPassword, newPassword, hashedNewPassword);
}
changeAccountDetails(passwordCheck: string, realName?: string, email?: string, country?: string): void {
SessionCommands.accountEdit(passwordCheck, realName, email, country);
}
changeAccountImage(image: Uint8Array): void {
SessionCommands.accountImage(image);
}
sendDirectMessage(userName: string, message: string): void {
SessionCommands.message(userName, message);
}
getUserInfo(userName: string): void {
SessionCommands.getUserInfo(userName);
}
getUserGames(userName: string): void {
SessionCommands.getGamesOfUser(userName);
}
}

View file

@ -0,0 +1,23 @@
import type { IWebClientRequest } from '@app/websocket';
import { AuthenticationRequestImpl } from './AuthenticationRequestImpl';
import { SessionRequestImpl } from './SessionRequestImpl';
import { RoomsRequestImpl } from './RoomsRequestImpl';
import { AdminRequestImpl } from './AdminRequestImpl';
import { ModeratorRequestImpl } from './ModeratorRequestImpl';
export { AuthenticationRequestImpl } from './AuthenticationRequestImpl';
export { SessionRequestImpl } from './SessionRequestImpl';
export { RoomsRequestImpl } from './RoomsRequestImpl';
export { AdminRequestImpl } from './AdminRequestImpl';
export { ModeratorRequestImpl } from './ModeratorRequestImpl';
export function createWebClientRequest(): IWebClientRequest {
return {
authentication: new AuthenticationRequestImpl(),
session: new SessionRequestImpl(),
rooms: new RoomsRequestImpl(),
admin: new AdminRequestImpl(),
moderator: new ModeratorRequestImpl(),
};
}

View file

@ -0,0 +1,20 @@
import type { IAdminResponse } from '@app/websocket';
import { ServerDispatch } from '@app/store';
export class AdminResponseImpl implements IAdminResponse {
adjustMod(userName: string, shouldBeMod: boolean, shouldBeJudge: boolean): void {
ServerDispatch.adjustMod(userName, shouldBeMod, shouldBeJudge);
}
reloadConfig(): void {
ServerDispatch.reloadConfig();
}
shutdownServer(): void {
ServerDispatch.shutdownServer();
}
updateServerMessage(): void {
ServerDispatch.updateServerMessage();
}
}

View file

@ -0,0 +1,125 @@
import { Data } from '@app/types';
import type { IGameResponse } from '@app/websocket';
import { GameDispatch } from '@app/store';
export class GameResponseImpl implements IGameResponse {
clearStore(): void {
GameDispatch.clearStore();
}
gameStateChanged(gameId: number, data: Data.Event_GameStateChanged): void {
GameDispatch.gameStateChanged(gameId, data);
}
playerJoined(gameId: number, playerProperties: Data.ServerInfo_PlayerProperties): void {
GameDispatch.playerJoined(gameId, playerProperties);
}
playerLeft(gameId: number, playerId: number, reason: number): void {
GameDispatch.playerLeft(gameId, playerId, reason);
}
playerPropertiesChanged(gameId: number, playerId: number, properties: Data.ServerInfo_PlayerProperties): void {
GameDispatch.playerPropertiesChanged(gameId, playerId, properties);
}
gameClosed(gameId: number): void {
GameDispatch.gameClosed(gameId);
}
gameHostChanged(gameId: number, hostId: number): void {
GameDispatch.gameHostChanged(gameId, hostId);
}
kicked(gameId: number): void {
GameDispatch.kicked(gameId);
}
gameSay(gameId: number, playerId: number, message: string): void {
GameDispatch.gameSay(gameId, playerId, message);
}
cardMoved(gameId: number, playerId: number, data: Data.Event_MoveCard): void {
GameDispatch.cardMoved(gameId, playerId, data);
}
cardFlipped(gameId: number, playerId: number, data: Data.Event_FlipCard): void {
GameDispatch.cardFlipped(gameId, playerId, data);
}
cardDestroyed(gameId: number, playerId: number, data: Data.Event_DestroyCard): void {
GameDispatch.cardDestroyed(gameId, playerId, data);
}
cardAttached(gameId: number, playerId: number, data: Data.Event_AttachCard): void {
GameDispatch.cardAttached(gameId, playerId, data);
}
tokenCreated(gameId: number, playerId: number, data: Data.Event_CreateToken): void {
GameDispatch.tokenCreated(gameId, playerId, data);
}
cardAttrChanged(gameId: number, playerId: number, data: Data.Event_SetCardAttr): void {
GameDispatch.cardAttrChanged(gameId, playerId, data);
}
cardCounterChanged(gameId: number, playerId: number, data: Data.Event_SetCardCounter): void {
GameDispatch.cardCounterChanged(gameId, playerId, data);
}
arrowCreated(gameId: number, playerId: number, data: Data.Event_CreateArrow): void {
GameDispatch.arrowCreated(gameId, playerId, data);
}
arrowDeleted(gameId: number, playerId: number, data: Data.Event_DeleteArrow): void {
GameDispatch.arrowDeleted(gameId, playerId, data);
}
counterCreated(gameId: number, playerId: number, data: Data.Event_CreateCounter): void {
GameDispatch.counterCreated(gameId, playerId, data);
}
counterSet(gameId: number, playerId: number, data: Data.Event_SetCounter): void {
GameDispatch.counterSet(gameId, playerId, data);
}
counterDeleted(gameId: number, playerId: number, data: Data.Event_DelCounter): void {
GameDispatch.counterDeleted(gameId, playerId, data);
}
cardsDrawn(gameId: number, playerId: number, data: Data.Event_DrawCards): void {
GameDispatch.cardsDrawn(gameId, playerId, data);
}
cardsRevealed(gameId: number, playerId: number, data: Data.Event_RevealCards): void {
GameDispatch.cardsRevealed(gameId, playerId, data);
}
zoneShuffled(gameId: number, playerId: number, data: Data.Event_Shuffle): void {
GameDispatch.zoneShuffled(gameId, playerId, data);
}
dieRolled(gameId: number, playerId: number, data: Data.Event_RollDie): void {
GameDispatch.dieRolled(gameId, playerId, data);
}
activePlayerSet(gameId: number, activePlayerId: number): void {
GameDispatch.activePlayerSet(gameId, activePlayerId);
}
activePhaseSet(gameId: number, phase: number): void {
GameDispatch.activePhaseSet(gameId, phase);
}
turnReversed(gameId: number, reversed: boolean): void {
GameDispatch.turnReversed(gameId, reversed);
}
zoneDumped(gameId: number, playerId: number, data: Data.Event_DumpZone): void {
GameDispatch.zoneDumped(gameId, playerId, data);
}
zonePropertiesChanged(gameId: number, playerId: number, data: Data.Event_ChangeZoneProperties): void {
GameDispatch.zonePropertiesChanged(gameId, playerId, data);
}
}

View file

@ -0,0 +1,45 @@
import { Data } from '@app/types';
import type { IModeratorResponse } from '@app/websocket';
import { ServerDispatch } from '@app/store';
export class ModeratorResponseImpl implements IModeratorResponse {
banFromServer(userName: string): void {
ServerDispatch.banFromServer(userName);
}
banHistory(userName: string, banHistory: Data.ServerInfo_Ban[]): void {
ServerDispatch.banHistory(userName, banHistory);
}
viewLogs(logs: Data.ServerInfo_ChatMessage[]): void {
ServerDispatch.viewLogs(logs);
}
warnHistory(userName: string, warnHistory: Data.ServerInfo_Warning[]): void {
ServerDispatch.warnHistory(userName, warnHistory);
}
warnListOptions(warnList: Data.Response_WarnList[]): void {
ServerDispatch.warnListOptions(warnList);
}
warnUser(userName: string): void {
ServerDispatch.warnUser(userName);
}
grantReplayAccess(replayId: number, moderatorName: string): void {
ServerDispatch.grantReplayAccess(replayId, moderatorName);
}
forceActivateUser(usernameToActivate: string, moderatorName: string): void {
ServerDispatch.forceActivateUser(usernameToActivate, moderatorName);
}
getAdminNotes(userName: string, notes: string): void {
ServerDispatch.getAdminNotes(userName, notes);
}
updateAdminNotes(userName: string, notes: string): void {
ServerDispatch.updateAdminNotes(userName, notes);
}
}

View file

@ -0,0 +1,49 @@
import { Data, Enriched } from '@app/types';
import type { IRoomResponse } from '@app/websocket';
import { RoomsDispatch } from '@app/store';
export class RoomResponseImpl implements IRoomResponse {
clearStore(): void {
RoomsDispatch.clearStore();
}
joinRoom(roomInfo: Data.ServerInfo_Room): void {
RoomsDispatch.joinRoom(roomInfo);
}
leaveRoom(roomId: number): void {
RoomsDispatch.leaveRoom(roomId);
}
updateRooms(rooms: Data.ServerInfo_Room[]): void {
RoomsDispatch.updateRooms(rooms);
}
updateGames(roomId: number, gameList: Data.ServerInfo_Game[]): void {
RoomsDispatch.updateGames(roomId, gameList);
}
addMessage(roomId: number, message: Enriched.Message): void {
RoomsDispatch.addMessage(roomId, message);
}
userJoined(roomId: number, user: Data.ServerInfo_User): void {
RoomsDispatch.userJoined(roomId, user);
}
userLeft(roomId: number, name: string): void {
RoomsDispatch.userLeft(roomId, name);
}
removeMessages(roomId: number, name: string, amount: number): void {
RoomsDispatch.removeMessages(roomId, name, amount);
}
gameCreated(roomId: number): void {
RoomsDispatch.gameCreated(roomId);
}
joinedGame(roomId: number, gameId: number): void {
RoomsDispatch.joinedGame(roomId, gameId);
}
}

View file

@ -0,0 +1,232 @@
import { App, Data, Enriched } from '@app/types';
import type { ISessionResponse } from '@app/websocket';
import { GameDispatch, RoomsDispatch, ServerDispatch } from '@app/store';
export class SessionResponseImpl implements ISessionResponse {
initialized(): void {
ServerDispatch.initialized();
}
connectionAttempted(): void {
ServerDispatch.connectionAttempted();
}
clearStore(): void {
ServerDispatch.clearStore();
}
loginSuccessful(options: Enriched.LoginSuccessContext): void {
ServerDispatch.loginSuccessful(options);
}
loginFailed(): void {
ServerDispatch.loginFailed();
}
connectionFailed(): void {
ServerDispatch.connectionFailed();
}
testConnectionSuccessful(): void {
ServerDispatch.testConnectionSuccessful();
}
testConnectionFailed(): void {
ServerDispatch.testConnectionFailed();
}
updateBuddyList(buddyList: Data.ServerInfo_User[]): void {
ServerDispatch.updateBuddyList(buddyList);
}
addToBuddyList(user: Data.ServerInfo_User): void {
ServerDispatch.addToBuddyList(user);
}
removeFromBuddyList(userName: string): void {
ServerDispatch.removeFromBuddyList(userName);
}
updateIgnoreList(ignoreList: Data.ServerInfo_User[]): void {
ServerDispatch.updateIgnoreList(ignoreList);
}
addToIgnoreList(user: Data.ServerInfo_User): void {
ServerDispatch.addToIgnoreList(user);
}
removeFromIgnoreList(userName: string): void {
ServerDispatch.removeFromIgnoreList(userName);
}
updateInfo(name: string, version: string): void {
ServerDispatch.updateInfo(name, version);
}
updateStatus(state: App.StatusEnum, description: string): void {
if (state === App.StatusEnum.DISCONNECTED) {
GameDispatch.clearStore();
RoomsDispatch.clearStore();
ServerDispatch.clearStore();
}
ServerDispatch.updateStatus(state, description);
}
updateUser(user: Data.ServerInfo_User): void {
ServerDispatch.updateUser(user);
}
updateUsers(users: Data.ServerInfo_User[]): void {
ServerDispatch.updateUsers(users);
}
userJoined(user: Data.ServerInfo_User): void {
ServerDispatch.userJoined(user);
}
userLeft(userName: string): void {
ServerDispatch.userLeft(userName);
}
serverMessage(message: string): void {
ServerDispatch.serverMessage(message);
}
accountAwaitingActivation(options: Enriched.PendingActivationContext): void {
ServerDispatch.accountAwaitingActivation(options);
}
accountActivationSuccess(): void {
ServerDispatch.accountActivationSuccess();
}
accountActivationFailed(): void {
ServerDispatch.accountActivationFailed();
}
registrationRequiresEmail(): void {
ServerDispatch.registrationRequiresEmail();
}
registrationSuccess(): void {
ServerDispatch.registrationSuccess();
}
registrationFailed(reason: string, endTime?: number): void {
ServerDispatch.registrationFailed(reason, endTime);
}
registrationEmailError(error: string): void {
ServerDispatch.registrationEmailError(error);
}
registrationPasswordError(error: string): void {
ServerDispatch.registrationPasswordError(error);
}
registrationUserNameError(error: string): void {
ServerDispatch.registrationUserNameError(error);
}
resetPasswordChallenge(): void {
ServerDispatch.resetPasswordChallenge();
}
resetPassword(): void {
ServerDispatch.resetPassword();
}
resetPasswordSuccess(): void {
ServerDispatch.resetPasswordSuccess();
}
resetPasswordFailed(): void {
ServerDispatch.resetPasswordFailed();
}
accountPasswordChange(): void {
ServerDispatch.accountPasswordChange();
}
accountEditChanged(realName?: string, email?: string, country?: string): void {
ServerDispatch.accountEditChanged({ realName, email, country });
}
accountImageChanged(avatarBmp: Uint8Array): void {
ServerDispatch.accountImageChanged({ avatarBmp });
}
getUserInfo(userInfo: Data.ServerInfo_User): void {
ServerDispatch.getUserInfo(userInfo);
}
getGamesOfUser(userName: string, response: Data.Response_GetGamesOfUser): void {
ServerDispatch.gamesOfUser(userName, response);
}
gameJoined(gameJoinedData: Data.Event_GameJoined): void {
GameDispatch.gameJoined(gameJoinedData);
}
notifyUser(notification: Data.Event_NotifyUser): void {
ServerDispatch.notifyUser(notification);
}
playerPropertiesChanged(gameId: number, playerId: number, payload: Data.Event_PlayerPropertiesChanged): void {
if (payload.playerProperties) {
GameDispatch.playerPropertiesChanged(gameId, playerId, payload.playerProperties);
}
}
serverShutdown(data: Data.Event_ServerShutdown): void {
ServerDispatch.serverShutdown(data);
}
userMessage(messageData: Data.Event_UserMessage): void {
ServerDispatch.userMessage(messageData);
}
addToList(list: string, userName: string): void {
ServerDispatch.addToList(list, userName);
}
removeFromList(list: string, userName: string): void {
ServerDispatch.removeFromList(list, userName);
}
deleteServerDeck(deckId: number): void {
ServerDispatch.deckDelete(deckId);
}
updateServerDecks(deckList: Data.Response_DeckList): void {
ServerDispatch.backendDecks(deckList);
}
uploadServerDeck(path: string, treeItem: Data.ServerInfo_DeckStorage_TreeItem): void {
ServerDispatch.deckUpload(path, treeItem);
}
createServerDeckDir(path: string, dirName: string): void {
ServerDispatch.deckNewDir(path, dirName);
}
deleteServerDeckDir(path: string): void {
ServerDispatch.deckDelDir(path);
}
replayList(matchList: Data.ServerInfo_ReplayMatch[]): void {
ServerDispatch.replayList(matchList);
}
replayAdded(matchInfo: Data.ServerInfo_ReplayMatch): void {
ServerDispatch.replayAdded(matchInfo);
}
replayModifyMatch(gameId: number, doNotHide: boolean): void {
ServerDispatch.replayModifyMatch(gameId, doNotHide);
}
replayDeleteMatch(gameId: number): void {
ServerDispatch.replayDeleteMatch(gameId);
}
}

View file

@ -0,0 +1,23 @@
import type { IWebClientResponse } from '@app/websocket';
import { SessionResponseImpl } from './SessionResponseImpl';
import { RoomResponseImpl } from './RoomResponseImpl';
import { GameResponseImpl } from './GameResponseImpl';
import { AdminResponseImpl } from './AdminResponseImpl';
import { ModeratorResponseImpl } from './ModeratorResponseImpl';
export { SessionResponseImpl } from './SessionResponseImpl';
export { RoomResponseImpl } from './RoomResponseImpl';
export { GameResponseImpl } from './GameResponseImpl';
export { AdminResponseImpl } from './AdminResponseImpl';
export { ModeratorResponseImpl } from './ModeratorResponseImpl';
export function createWebClientResponse(): IWebClientResponse {
return {
session: new SessionResponseImpl(),
room: new RoomResponseImpl(),
game: new GameResponseImpl(),
admin: new AdminResponseImpl(),
moderator: new ModeratorResponseImpl(),
};
}

View file

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

View file

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

View file

@ -13,7 +13,7 @@ import AddIcon from '@mui/icons-material/Add';
import EditRoundedIcon from '@mui/icons-material/Edit';
import ErrorOutlinedIcon from '@mui/icons-material/ErrorOutlined';
import { AuthenticationService } from '@app/api';
import { request } from '@app/api';
import { KnownHostDialog } from '@app/dialogs';
import { useReduxEffect } from '@app/hooks';
import { HostDTO } from '@app/services';
@ -197,7 +197,7 @@ const KnownHosts = (props) => {
setTestingConnection(TestConnection.TESTING);
const options = { ...App.getHostPort(hostsState.selectedHost) };
AuthenticationService.testConnection(options);
request.authentication.testConnection(options);
}
return (

View file

@ -6,7 +6,7 @@ import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
import { Images } from '@app/images';
import { SessionService } from '@app/api';
import { request } from '@app/api';
import { ServerSelectors } from '@app/store';
import { App, Data } from '@app/types';
import { useAppSelector } from '@app/store';
@ -28,23 +28,23 @@ const UserDisplay = ({ user }: UserDisplayProps) => {
const handleClose = () => setPosition(null);
const isABuddy = buddyList.filter(u => u.name === user.name).length;
const isIgnored = ignoreList.filter(u => u.name === user.name).length;
const isABuddy = Boolean(buddyList[user.name]);
const isIgnored = Boolean(ignoreList[user.name]);
const onAddBuddy = () => {
SessionService.addToBuddyList(user.name);
request.session.addToBuddyList(user.name);
handleClose();
};
const onRemoveBuddy = () => {
SessionService.removeFromBuddyList(user.name);
request.session.removeFromBuddyList(user.name);
handleClose();
};
const onAddIgnore = () => {
SessionService.addToIgnoreList(user.name);
request.session.addToIgnoreList(user.name);
handleClose();
};
const onRemoveIgnore = () => {
SessionService.removeFromIgnoreList(user.name);
request.session.removeFromIgnoreList(user.name);
handleClose();
};

View file

@ -7,7 +7,7 @@ import ListItemButton from '@mui/material/ListItemButton';
import Paper from '@mui/material/Paper';
import { UserDisplay, VirtualList, AuthGuard, LanguageDropdown } from '@app/components';
import { AuthenticationService, SessionService } from '@app/api';
import { request } from '@app/api';
import { ServerSelectors } from '@app/store';
import Layout from '../Layout/Layout';
import { useAppSelector } from '@app/store';
@ -18,8 +18,8 @@ import AddToIgnore from './AddToIgnore';
import './Account.css';
const Account = () => {
const buddyList = useAppSelector(state => ServerSelectors.getBuddyList(state));
const ignoreList = useAppSelector(state => ServerSelectors.getIgnoreList(state));
const buddyList = useAppSelector(state => ServerSelectors.getSortedBuddyList(state));
const ignoreList = useAppSelector(state => ServerSelectors.getSortedIgnoreList(state));
const serverName = useAppSelector(state => ServerSelectors.getName(state));
const serverVersion = useAppSelector(state => ServerSelectors.getVersion(state));
const user = useAppSelector(state => ServerSelectors.getUser(state));
@ -29,11 +29,11 @@ const Account = () => {
const { t } = useTranslation();
const handleAddToBuddies = ({ userName }) => {
SessionService.addToBuddyList(userName);
request.session.addToBuddyList(userName);
};
const handleAddToIgnore = ({ userName }) => {
SessionService.addToIgnoreList(userName);
request.session.addToIgnoreList(userName);
};
return (
@ -91,7 +91,13 @@ const Account = () => {
<Paper className="account-details">
<p>Server Name: {serverName}</p>
<p>Server Version: {serverVersion}</p>
<Button color="primary" variant="contained" onClick={() => AuthenticationService.disconnect()}>{ t('Common.disconnect') }</Button>
<Button
color="primary"
variant="contained"
onClick={() => request.authentication.disconnect()}
>
{ t('Common.disconnect') }
</Button>
<div className="account-details__lang">
<LanguageDropdown />

View file

@ -8,7 +8,7 @@ import CloseIcon from '@mui/icons-material/Close';
import MailOutlineRoundedIcon from '@mui/icons-material/MailOutlineRounded';
import MenuRoundedIcon from '@mui/icons-material/MenuRounded';
import { AuthenticationService, RoomsService } from '@app/api';
import { request } from '@app/api';
import { CardImportDialog } from '@app/dialogs';
import { Images } from '@app/images';
import { RoomsSelectors, ServerSelectors } from '@app/store';
@ -25,8 +25,8 @@ interface LeftNavState {
const LeftNav = () => {
const joinedRooms = useAppSelector(state => RoomsSelectors.getJoinedRooms(state));
const serverState = useAppSelector(state => ServerSelectors.getState(state));
const user = useAppSelector(state => ServerSelectors.getUser(state));
const isConnected = useAppSelector(ServerSelectors.getIsConnected);
const isModerator = useAppSelector(ServerSelectors.getIsUserModerator);
const navigate = useNavigate();
const [state, setState] = useState<LeftNavState>({
anchorEl: null,
@ -40,7 +40,7 @@ const LeftNav = () => {
'Replays',
];
if (user && AuthenticationService.isModerator(user)) {
if (isModerator) {
options = [
...options,
'Administration',
@ -49,7 +49,7 @@ const LeftNav = () => {
}
setState(s => ({ ...s, options }));
}, [user]);
}, [isModerator]);
const handleMenuOpen = (event) => {
setState(s => ({ ...s, anchorEl: event.target }));
@ -66,7 +66,7 @@ const LeftNav = () => {
const leaveRoom = (event, roomId) => {
event.preventDefault();
RoomsService.leaveRoom(roomId);
request.rooms.leaveRoom(roomId);
};
const openImportCardWizard = () => {
@ -85,11 +85,11 @@ const LeftNav = () => {
<NavLink to={App.RouteEnum.SERVER}>
<img src={Images.Logo} alt="logo" />
</NavLink>
{ AuthenticationService.isConnected(serverState) && (
{ isConnected && (
<span className="LeftNav-server__indicator"></span>
) }
</div>
{ AuthenticationService.isConnected(serverState) && (
{ isConnected && (
<div className="LeftNav-content">
<nav className="LeftNav-nav">
<nav className="LeftNav-nav__links">
@ -98,7 +98,7 @@ const LeftNav = () => {
className="LeftNav-nav__link-btn"
to={
joinedRooms.length
? generatePath(App.RouteEnum.ROOM, { roomId: joinedRooms[0].roomId.toString() })
? generatePath(App.RouteEnum.ROOM, { roomId: joinedRooms[0].info.roomId.toString() })
: App.RouteEnum.SERVER
}
>
@ -106,14 +106,14 @@ const LeftNav = () => {
<ArrowDropDownIcon className="LeftNav-nav__link-btn__icon" fontSize="small" />
</NavLink>
<div className="LeftNav-nav__link-menu">
{joinedRooms.map(({ name, roomId }) => (
<div className="LeftNav-nav__link-menu__item" key={roomId}>
{joinedRooms.map((room) => (
<div className="LeftNav-nav__link-menu__item" key={room.info.roomId}>
<NavLink className="LeftNav-nav__link-menu__btn"
to={ generatePath(App.RouteEnum.ROOM, { roomId: roomId.toString() }) }
to={ generatePath(App.RouteEnum.ROOM, { roomId: room.info.roomId.toString() }) }
>
{name}
{room.info.name}
<IconButton size="small" edge="end" onClick={event => leaveRoom(event, roomId)}>
<IconButton size="small" edge="end" onClick={event => leaveRoom(event, room.info.roomId)}>
<CloseIcon style={{ fontSize: 10, color: 'white' }} />
</IconButton>
</NavLink>

View file

@ -6,7 +6,7 @@ import Button from '@mui/material/Button';
import Paper from '@mui/material/Paper';
import Typography from '@mui/material/Typography';
import { AuthenticationService } from '@app/api';
import { request } from '@app/api';
import { RegistrationDialog, RequestPasswordResetDialog, ResetPasswordDialog, AccountActivationDialog } from '@app/dialogs';
import { LanguageDropdown } from '@app/components';
import { LoginForm } from '@app/forms';
@ -65,12 +65,10 @@ const Root = styled('div')(({ theme }) => ({
}));
const Login = () => {
const state = useAppSelector(s => ServerSelectors.getState(s));
const description = useAppSelector(s => ServerSelectors.getDescription(s));
const isConnected = useAppSelector(ServerSelectors.getIsConnected);
const { t } = useTranslation();
const isConnected = AuthenticationService.isConnected(state);
const [pendingActivationOptions, setPendingActivationOptions] = useState<Enriched.PendingActivationContext | null>(null);
const [rememberLogin, setRememberLogin] = useState(null);
@ -104,7 +102,7 @@ const Login = () => {
setPendingActivationOptions(null);
}, ServerTypes.ACCOUNT_ACTIVATION_SUCCESS, []);
useReduxEffect(({ options }) => {
useReduxEffect(({ payload: { options } }) => {
setPendingActivationOptions(options);
closeRegistrationDialog();
openActivateAccountDialog();
@ -114,7 +112,7 @@ const Login = () => {
resetSubmitButton();
}, [ServerTypes.CONNECTION_FAILED, ServerTypes.LOGIN_FAILED], []);
useReduxEffect(({ options: { hashedPassword } }) => {
useReduxEffect(({ payload: { options: { hashedPassword } } }) => {
updateHost(hashedPassword, rememberLogin);
}, ServerTypes.LOGIN_SUCCESSFUL, [rememberLogin]);
@ -136,7 +134,7 @@ const Login = () => {
options.hashedPassword = selectedHost.hashedPassword;
}
AuthenticationService.login(options);
request.authentication.login(options);
}, []);
const [submitButtonDisabled, resetSubmitButton, handleLogin] = useFireOnce(onSubmitLogin);
@ -155,7 +153,7 @@ const Login = () => {
setRememberLogin(registerForm);
const { userName, password, email, country, realName, selectedHost } = registerForm;
AuthenticationService.register({
request.authentication.register({
...App.getHostPort(selectedHost),
userName,
password,
@ -169,7 +167,7 @@ const Login = () => {
if (!pendingActivationOptions) {
return;
}
AuthenticationService.activateAccount({
request.authentication.activateAccount({
host: pendingActivationOptions.host,
port: pendingActivationOptions.port,
userName: pendingActivationOptions.userName,
@ -182,17 +180,17 @@ const Login = () => {
const { host, port } = App.getHostPort(selectedHost);
if (email) {
AuthenticationService.resetPasswordChallenge({ userName, email, host, port });
request.authentication.resetPasswordChallenge({ userName, email, host, port });
} else {
setUserToResetPassword(userName);
AuthenticationService.resetPasswordRequest({ userName, host, port });
request.authentication.resetPasswordRequest({ userName, host, port });
}
};
const handleResetPasswordDialogSubmit = ({ userName, token, newPassword, selectedHost }) => {
const { host, port } = App.getHostPort(selectedHost);
AuthenticationService.resetPassword({ userName, token, newPassword, host, port });
request.authentication.resetPassword({ userName, token, newPassword, host, port });
};
const skipTokenRequest = (userName) => {

View file

@ -1,5 +1,4 @@
import React from 'react';
import * as _ from 'lodash';
import AppBar from '@mui/material/AppBar';
import Box from '@mui/material/Box';
@ -98,13 +97,13 @@ const Results = ({ headerCells, logs }) => (
<Table size="small">
<TableHead>
<TableRow>
{ _.map(headerCells, ({ label }) => (
{ headerCells.map(({ label }) => (
<TableCell key={label}>{label}</TableCell>
))}
</TableRow>
</TableHead>
<TableBody>
{ _.map(logs, ({ time, senderName, senderIp, message, targetId, targetName }, index) => (
{ logs.map(({ time, senderName, senderIp, message, targetId, targetName }, index) => (
<TableRow key={index}>
<TableCell>{time}</TableCell>
<TableCell>{senderName}</TableCell>

View file

@ -1,8 +1,7 @@
// eslint-disable-next-line
import React, { useEffect } from "react";
import * as _ from 'lodash';
import { ModeratorService } from '@app/api';
import { request } from '@app/api';
import { AuthGuard, ModGuard } from '@app/components';
import { SearchForm } from '@app/forms';
import { ServerDispatch, ServerSelectors } from '@app/store';
@ -23,33 +22,27 @@ const Logs = () => {
}, []);
const trimFields = (fields) => {
return _.reduce(fields, (obj: any, field, key) => {
const result: any = {};
for (const [key, field] of Object.entries(fields)) {
if (typeof field === 'string') {
const trimmed = _.trim(field);
if (!!trimmed) {
obj[key] = trimmed;
const trimmed = field.trim();
if (trimmed) {
result[key] = trimmed;
}
} else {
obj[key] = field;
result[key] = field;
}
return obj;
}, {});
}
return result;
};
const flattenLogLocations = (logLocations) => {
return _.reduce(logLocations, (arr: any[], loc, key) => {
arr.push(key);
return arr;
}, []);
};
const flattenLogLocations = (logLocations) => Object.keys(logLocations);
const onSubmit = (fields: Data.ViewLogHistoryParams) => {
const trimmedFields: any = trimFields(fields);
const { userName, ipAddress, gameName, gameId, message, logLocation } = trimmedFields;
const required = _.filter({
userName, ipAddress, gameName, gameId, message
}, field => field);
const required = [userName, ipAddress, gameName, gameId, message].filter(Boolean);
if (logLocation) {
trimmedFields.logLocation = flattenLogLocations(logLocation);
@ -57,8 +50,8 @@ const Logs = () => {
trimmedFields.maximumResults = MAXIMUM_RESULTS;
if (_.size(required)) {
ModeratorService.viewLogHistory(trimmedFields);
if (required.length) {
request.moderator.viewLogHistory(trimmedFields);
} else {
// @TODO use yet-to-be-implemented banner/alert
}

View file

@ -1,6 +1,5 @@
// eslint-disable-next-line
import React from "react";
import * as _ from 'lodash';
import Table from '@mui/material/Table';
import TableBody from '@mui/material/TableBody';
@ -24,20 +23,21 @@ interface GamesProps {
}
const Games = ({ room }: GamesProps) => {
const roomId = room.info.roomId;
const sortBy = useAppSelector(state => RoomsSelectors.getSortGamesBy(state));
const sortedGames = useAppSelector(state => RoomsSelectors.getSortedRoomGames(state, roomId));
const headerCells = [
{ label: 'Age', field: 'startTime' },
{ label: 'Description', field: 'description' },
{ label: 'Creator', field: 'creatorInfo.name' },
{ label: 'Age', field: 'info.startTime' },
{ label: 'Description', field: 'info.description' },
{ label: 'Creator', field: 'info.creatorInfo.name' },
{ label: 'Type', field: 'gameType' },
{ label: 'Restrictions' },
{ label: 'Players' },
{ label: 'Spectators', field: 'spectatorsCount' },
{ label: 'Spectators', field: 'info.spectatorsCount' },
];
const handleSort = (sortByField) => {
const { roomId } = room;
const { field, order } = SortUtil.toggleSortBy(sortByField, sortBy);
RoomsDispatch.sortGames(roomId, field, order);
};
@ -49,10 +49,10 @@ const Games = ({ room }: GamesProps) => {
const isBuddiesOnlyGame = ({ onlyBuddies }) => !onlyBuddies;
const games = room.gameList.filter(game => (
isUnavailableGame(game) &&
isPasswordProtectedGame(game) &&
isBuddiesOnlyGame(game)
const games = sortedGames.filter(game => (
isUnavailableGame(game.info) &&
isPasswordProtectedGame(game.info) &&
isBuddiesOnlyGame(game.info)
));
return (
@ -60,7 +60,7 @@ const Games = ({ room }: GamesProps) => {
<Table size="small">
<TableHead>
<TableRow>
{ _.map(headerCells, ({ label, field }) => {
{ headerCells.map(({ label, field }) => {
const active = field === sortBy.field;
const order = sortBy.order.toLowerCase();
const sortDirection = active ? (order === 'asc' ? 'asc' : 'desc') : false;
@ -82,25 +82,29 @@ const Games = ({ room }: GamesProps) => {
</TableRow>
</TableHead>
<TableBody>
{ _.map(games, ({ description, gameId, gameType, creatorInfo, maxPlayers, playerCount, spectatorsCount, startTime }) => (
<TableRow key={gameId}>
<TableCell className="games-header__cell single-line-ellipsis">{startTime}</TableCell>
<TableCell className="games-header__cell">
<Tooltip title={description} placement="bottom-start" enterDelay={500}>
<div className="single-line-ellipsis">
{description}
</div>
</Tooltip>
</TableCell>
<TableCell className="games-header__cell">
<UserDisplay user={ creatorInfo } />
</TableCell>
<TableCell className="games-header__cell single-line-ellipsis">{gameType}</TableCell>
<TableCell className="games-header__cell single-line-ellipsis">?</TableCell>
<TableCell className="games-header__cell single-line-ellipsis">{`${playerCount}/${maxPlayers}`}</TableCell>
<TableCell className="games-header__cell single-line-ellipsis">{spectatorsCount}</TableCell>
</TableRow>
))}
{ games.map((game) => {
const { info, gameType } = game;
const { description, gameId, creatorInfo, maxPlayers, playerCount, spectatorsCount, startTime } = info;
return (
<TableRow key={gameId}>
<TableCell className="games-header__cell single-line-ellipsis">{startTime}</TableCell>
<TableCell className="games-header__cell">
<Tooltip title={description} placement="bottom-start" enterDelay={500}>
<div className="single-line-ellipsis">
{description}
</div>
</Tooltip>
</TableCell>
<TableCell className="games-header__cell">
<UserDisplay user={ creatorInfo } />
</TableCell>
<TableCell className="games-header__cell single-line-ellipsis">{gameType}</TableCell>
<TableCell className="games-header__cell single-line-ellipsis">?</TableCell>
<TableCell className="games-header__cell single-line-ellipsis">{`${playerCount}/${maxPlayers}`}</TableCell>
<TableCell className="games-header__cell single-line-ellipsis">{spectatorsCount}</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>

View file

@ -1,6 +1,5 @@
// eslint-disable-next-line
import React from "react";
import * as _ from 'lodash';
import Table from '@mui/material/Table';
import TableBody from '@mui/material/TableBody';
@ -24,20 +23,21 @@ interface OpenGamesProps {
}
const OpenGames = ({ room }: OpenGamesProps) => {
const roomId = room.info.roomId;
const sortBy = useAppSelector(state => RoomsSelectors.getSortGamesBy(state));
const sortedGames = useAppSelector(state => RoomsSelectors.getSortedRoomGames(state, roomId));
const headerCells = [
{ label: 'Age', field: 'startTime' },
{ label: 'Description', field: 'description' },
{ label: 'Creator', field: 'creatorInfo.name' },
{ label: 'Age', field: 'info.startTime' },
{ label: 'Description', field: 'info.description' },
{ label: 'Creator', field: 'info.creatorInfo.name' },
{ label: 'Type', field: 'gameType' },
{ label: 'Restrictions' },
{ label: 'Players' },
{ label: 'Spectators', field: 'spectatorsCount' },
{ label: 'Spectators', field: 'info.spectatorsCount' },
];
const handleSort = (sortByField) => {
const { roomId } = room;
const { field, order } = SortUtil.toggleSortBy(sortByField, sortBy);
RoomsDispatch.sortGames(roomId, field, order);
};
@ -49,10 +49,10 @@ const OpenGames = ({ room }: OpenGamesProps) => {
const isBuddiesOnlyGame = ({ onlyBuddies }) => !onlyBuddies;
const games = room.gameList.filter(game => (
isUnavailableGame(game) &&
isPasswordProtectedGame(game) &&
isBuddiesOnlyGame(game)
const games = sortedGames.filter(game => (
isUnavailableGame(game.info) &&
isPasswordProtectedGame(game.info) &&
isBuddiesOnlyGame(game.info)
));
return (
@ -60,7 +60,7 @@ const OpenGames = ({ room }: OpenGamesProps) => {
<Table size="small">
<TableHead>
<TableRow>
{ _.map(headerCells, ({ label, field }) => {
{ headerCells.map(({ label, field }) => {
const active = field === sortBy.field;
const order = sortBy.order.toLowerCase();
const sortDirection = active ? (order === 'asc' ? 'asc' : 'desc') : false;
@ -82,25 +82,29 @@ const OpenGames = ({ room }: OpenGamesProps) => {
</TableRow>
</TableHead>
<TableBody>
{ _.map(games, ({ description, gameId, gameType, creatorInfo, maxPlayers, playerCount, spectatorsCount, startTime }) => (
<TableRow key={gameId}>
<TableCell className="games-header__cell single-line-ellipsis">{startTime}</TableCell>
<TableCell className="games-header__cell">
<Tooltip title={description} placement="bottom-start" enterDelay={500}>
<div className="single-line-ellipsis">
{description}
</div>
</Tooltip>
</TableCell>
<TableCell className="games-header__cell">
<UserDisplay user={ creatorInfo } />
</TableCell>
<TableCell className="games-header__cell single-line-ellipsis">{gameType}</TableCell>
<TableCell className="games-header__cell single-line-ellipsis">?</TableCell>
<TableCell className="games-header__cell single-line-ellipsis">{`${playerCount}/${maxPlayers}`}</TableCell>
<TableCell className="games-header__cell single-line-ellipsis">{spectatorsCount}</TableCell>
</TableRow>
))}
{ games.map((game) => {
const { info, gameType } = game;
const { description, gameId, creatorInfo, maxPlayers, playerCount, spectatorsCount, startTime } = info;
return (
<TableRow key={gameId}>
<TableCell className="games-header__cell single-line-ellipsis">{startTime}</TableCell>
<TableCell className="games-header__cell">
<Tooltip title={description} placement="bottom-start" enterDelay={500}>
<div className="single-line-ellipsis">
{description}
</div>
</Tooltip>
</TableCell>
<TableCell className="games-header__cell">
<UserDisplay user={ creatorInfo } />
</TableCell>
<TableCell className="games-header__cell single-line-ellipsis">{gameType}</TableCell>
<TableCell className="games-header__cell single-line-ellipsis">?</TableCell>
<TableCell className="games-header__cell single-line-ellipsis">{`${playerCount}/${maxPlayers}`}</TableCell>
<TableCell className="games-header__cell single-line-ellipsis">{spectatorsCount}</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>

View file

@ -4,7 +4,7 @@ import { useNavigate, useParams, generatePath } from 'react-router-dom';
import ListItemButton from '@mui/material/ListItemButton';
import Paper from '@mui/material/Paper';
import { RoomsService } from '@app/api';
import { request } from '@app/api';
import { ScrollToBottomOnChanges, ThreePaneLayout, UserDisplay, VirtualList, AuthGuard } from '@app/components';
import { RoomsSelectors } from '@app/store';
import { useAppSelector } from '@app/store';
@ -28,17 +28,17 @@ const Room = () => {
const roomId = parseInt(params.roomId, 0);
const room = rooms[roomId];
const roomMessages = messages[roomId];
const users = room.userList;
const users = useAppSelector(state => RoomsSelectors.getSortedRoomUsers(state, roomId));
useEffect(() => {
if (!joined.find(({ roomId: id }) => id === roomId)) {
if (!joined.find(r => r.info.roomId === roomId)) {
navigate(generatePath(App.RouteEnum.SERVER));
}
}, [joined]);
const handleRoomSay = ({ message }) => {
if (message) {
RoomsService.roomSay(roomId, message);
request.rooms.roomSay(roomId, message);
}
}

View file

@ -1,7 +1,6 @@
// eslint-disable-next-line
import React from "react";
import { generatePath, useNavigate } from 'react-router-dom';
import * as _ from 'lodash';
import Button from '@mui/material/Button';
import Table from '@mui/material/Table';
@ -11,7 +10,7 @@ import TableHead from '@mui/material/TableHead';
import TableRow from '@mui/material/TableRow';
import { RoomsService } from '@app/api';
import { request } from '@app/api';
import { App } from '@app/types';
import './Rooms.css';
@ -20,10 +19,10 @@ const Rooms = ({ rooms, joinedRooms }) => {
const navigate = useNavigate();
function onClick(roomId) {
if (_.find(joinedRooms, room => room.roomId === roomId)) {
if (joinedRooms.find(room => room.info.roomId === roomId)) {
navigate(generatePath(App.RouteEnum.ROOM, { roomId }));
} else {
RoomsService.joinRoom(roomId);
request.rooms.joinRoom(roomId);
}
}
@ -41,20 +40,23 @@ const Rooms = ({ rooms, joinedRooms }) => {
</TableRow>
</TableHead>
<TableBody>
{ _.map(rooms, ({ description, gameCount, name, permissionlevel, playerCount, roomId }) => (
<TableRow key={roomId}>
<TableCell>{name}</TableCell>
<TableCell>{description}</TableCell>
<TableCell>{permissionlevel}</TableCell>
<TableCell>{playerCount}</TableCell>
<TableCell>{gameCount}</TableCell>
<TableCell>
<Button size="small" color="primary" variant="contained" onClick={() => onClick(roomId)}>
Join
</Button>
</TableCell>
</TableRow>
))}
{ Object.values(rooms).map((room) => {
const { description, gameCount, name, permissionlevel, playerCount, roomId } = room.info;
return (
<TableRow key={roomId}>
<TableCell>{name}</TableCell>
<TableCell>{description}</TableCell>
<TableCell>{permissionlevel}</TableCell>
<TableCell>{playerCount}</TableCell>
<TableCell>{gameCount}</TableCell>
<TableCell>
<Button size="small" color="primary" variant="contained" onClick={() => onClick(roomId)}>
Join
</Button>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>

View file

@ -19,11 +19,11 @@ const Server = () => {
const message = useAppSelector(state => ServerSelectors.getMessage(state));
const rooms = useAppSelector(state => RoomsSelectors.getRooms(state));
const joinedRooms = useAppSelector(state => RoomsSelectors.getJoinedRooms(state));
const users = useAppSelector(state => ServerSelectors.getUsers(state));
const users = useAppSelector(state => ServerSelectors.getSortedUsers(state));
const navigate = useNavigate();
useReduxEffect((action: any) => {
const roomId = action.roomInfo.roomId.toString();
const roomId = action.payload.roomInfo.roomId.toString();
navigate(generatePath(App.RouteEnum.ROOM, { roomId }));
}, RoomsTypes.JOIN_ROOM, []);

View file

@ -37,15 +37,15 @@ const RegisterForm = ({ onSubmit }: RegisterFormProps) => {
openToast()
}, ServerTypes.REGISTRATION_SUCCESS);
useReduxEffect(({ error }) => {
useReduxEffect(({ payload: { error } }) => {
setEmailError(error);
}, ServerTypes.REGISTRATION_EMAIL_ERROR);
useReduxEffect(({ error }) => {
useReduxEffect(({ payload: { error } }) => {
setPasswordError(error);
}, ServerTypes.REGISTRATION_PASSWORD_ERROR);
useReduxEffect(({ error }) => {
useReduxEffect(({ payload: { error } }) => {
setUserNameError(error);
}, ServerTypes.REGISTRATION_USERNAME_ERROR);

View file

@ -1,13 +1,34 @@
import { useCallback } from 'react';
import { debounce, DebouncedFunc } from 'lodash';
type UseDebounceType = (...args: any) => any;
const DEBOUNCE_DELAY = 250;
export interface DebouncedFn<T extends UseDebounceType> {
(...args: Parameters<T>): void;
cancel(): void;
}
function debounce<T extends UseDebounceType>(fn: T, timeout: number): DebouncedFn<T> {
let timer: ReturnType<typeof setTimeout> | undefined;
const debounced = ((...args: Parameters<T>): void => {
if (timer !== undefined) {
clearTimeout(timer);
}
timer = setTimeout(() => fn(...args), timeout);
}) as DebouncedFn<T>;
debounced.cancel = (): void => {
if (timer !== undefined) {
clearTimeout(timer);
timer = undefined;
}
};
return debounced;
}
export function useDebounce<T extends UseDebounceType>(
fn: T,
deps: any[] = [],
timeout: number = DEBOUNCE_DELAY
): DebouncedFunc<T> {
): DebouncedFn<T> {
return useCallback(debounce(fn, timeout), deps);
}

View file

@ -6,7 +6,6 @@ File is adapted from https://github.com/Qeepsake/use-redux-effect under MIT Lice
import { useEffect, useRef, DependencyList } from 'react'
import { useStore } from 'react-redux'
import { castArray } from 'lodash'
// Actions are identified by string `type` at runtime, so the callback
// receives an untyped action object to allow free property access.
@ -43,7 +42,8 @@ export function useReduxEffect(
return;
}
lastHandledCountRef.current = action.count;
if (castArray(typeRef.current).includes(action.type)) {
const types = Array.isArray(typeRef.current) ? typeRef.current : [typeRef.current];
if (types.includes(action.type)) {
effectRef.current(action);
}
};
@ -52,6 +52,5 @@ export function useReduxEffect(
const unsubscribe = store.subscribe(check);
return (): void => unsubscribe();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps);
}

View file

@ -2,26 +2,43 @@
// creates the Redux store or connects to Redux DevTools.
import './polyfills';
import { StrictMode } from 'react';
import { StrictMode, useRef } from 'react';
import { createRoot } from 'react-dom/client';
import { StyledEngineProvider } from '@mui/material';
import { ThemeProvider } from '@mui/material/styles';
import { WebClient } from '@app/websocket';
import { createWebClientResponse, createWebClientRequest } from '@app/api';
import { AppShell } from '@app/containers';
import { materialTheme } from './material-theme';
import './i18n';
import './index.css';
const AppWithMaterialTheme = () => (
<StrictMode>
<StyledEngineProvider injectFirst>
<ThemeProvider theme={materialTheme}>
<AppShell />
</ThemeProvider>
</StyledEngineProvider>
</StrictMode>
);
function initWebClient() {
const initialized = useRef(false);
if (!initialized.current) {
initialized.current = true;
new WebClient(createWebClientResponse(), createWebClientRequest());
}
}
const AppWithMaterialTheme = () => {
// Instantiate the WebClient singleton before any container renders or any
// hook touches WebClient.instance.
initWebClient();
return (
<StrictMode>
<StyledEngineProvider injectFirst>
<ThemeProvider theme={materialTheme}>
<AppShell />
</ThemeProvider>
</StyledEngineProvider>
</StrictMode>
);
}
const container = document.getElementById('root');
const root = createRoot(container!);

View file

@ -57,6 +57,20 @@ export default class SortUtil {
}
}
/** Non-mutating variant: returns a new sorted array. Intended for use inside selectors. */
static sortedByField<T extends object>(arr: readonly T[], sortBy: App.SortBy): T[] {
const copy = [...arr];
SortUtil.sortByField(copy, sortBy);
return copy;
}
/** Non-mutating variant: returns a new sorted user array. Intended for use inside selectors. */
static sortedUsersByField(users: readonly Data.ServerInfo_User[], sortBy: App.SortBy): Data.ServerInfo_User[] {
const copy = [...users];
SortUtil.sortUsersByField(copy, sortBy);
return copy;
}
static toggleSortBy<F extends string>(field: F, sortBy: App.SortBy): { field: F; order: App.SortDirection } {
const sameField = field === sortBy.field;
const isASC = sortBy.order === App.SortDirection.ASC;
@ -133,19 +147,20 @@ export default class SortUtil {
private static resolveFieldChain(obj: object, field: string) {
const links = field.split('.');
if (links.length > 1) {
return links.reduce((obj, link) => {
const parsed = parseInt(link, 10);
if (parsed.toLocaleString() === 'NaN') {
return obj[link];
} else {
return obj[parsed];
}
}, obj) || null;
} else {
if (links.length === 1) {
return obj[field];
}
// Walk nested path; bail to null if we hit a missing intermediate object.
// Note: intentionally avoids `|| null` so falsy-but-valid leaf values
// (0, '', false) are preserved.
let cursor: any = obj;
for (const link of links) {
if (cursor == null) {
return null;
}
const parsed = parseInt(link, 10);
cursor = Number.isNaN(parsed) ? cursor[link] : cursor[parsed];
}
return cursor ?? null;
}
}

View file

@ -3,7 +3,7 @@ import { create } from '@bufbuild/protobuf';
import { Data, Enriched } from '@app/types';
describe('normalizeRoomInfo', () => {
it('builds gametypeMap from gametypeList and normalises games', () => {
it('builds gametypeMap from gametypeList and keys games by gameId', () => {
const room = create(Data.ServerInfo_RoomSchema, {
roomId: 1,
name: 'Lobby',
@ -15,9 +15,11 @@ describe('normalizeRoomInfo', () => {
const result = normalizeRoomInfo(room);
expect(result.info).toBe(room);
expect(result.gametypeMap).toEqual({ 1: 'Standard' });
expect(result.gameList).toHaveLength(1);
expect(result.gameList[0].gameType).toBe('Standard');
expect(Object.keys(result.games)).toHaveLength(1);
expect(result.games[10].gameType).toBe('Standard');
expect(result.games[10].info.gameId).toBe(10);
expect(result.order).toBe(0);
});
@ -25,7 +27,21 @@ describe('normalizeRoomInfo', () => {
const room = create(Data.ServerInfo_RoomSchema, { roomId: 2, name: 'Empty' });
const result = normalizeRoomInfo(room);
expect(result.gametypeMap).toEqual({});
expect(result.gameList).toEqual([]);
expect(result.games).toEqual({});
expect(result.users).toEqual({});
});
it('keys users by name', () => {
const room = create(Data.ServerInfo_RoomSchema, {
roomId: 1,
userList: [
create(Data.ServerInfo_UserSchema, { name: 'alice' }),
create(Data.ServerInfo_UserSchema, { name: 'bob' }),
],
});
const result = normalizeRoomInfo(room);
expect(result.users['alice']).toBeDefined();
expect(result.users['bob']).toBeDefined();
});
});
@ -34,6 +50,7 @@ describe('normalizeGameObject', () => {
const game = create(Data.ServerInfo_GameSchema, { gameId: 1, gameTypes: [5] });
const result = normalizeGameObject(game, { 5: 'Legacy' });
expect(result.gameType).toBe('Legacy');
expect(result.info).toBe(game);
});
it('returns empty string when no gameTypes', () => {
@ -42,10 +59,11 @@ describe('normalizeGameObject', () => {
expect(result.gameType).toBe('');
});
it('fills empty description with empty string', () => {
it('stores raw proto on info', () => {
const game = create(Data.ServerInfo_GameSchema, { gameId: 3 });
const result = normalizeGameObject(game, {});
expect(result.description).toBe('');
expect(result.info.gameId).toBe(3);
expect(result.info.description).toBe('');
});
});

View file

@ -8,31 +8,43 @@ export function normalizeGametypeMap(gametypeList: Data.ServerInfo_GameType[]):
}, {});
}
/** Flatten room gameTypes into a map object and normalize all games inside. */
/**
* Build an Enriched.Room (composition shape) from a raw proto. The proto is
* stored verbatim on `info` and the repeated collections are normalized into
* keyed maps alongside it. `info.gameList`, `info.userList`, `info.gametypeList`
* are left as the wire snapshot callers should always read the normalized
* fields, never those.
*/
export function normalizeRoomInfo(roomInfo: Data.ServerInfo_Room): Enriched.Room {
const gametypeMap = normalizeGametypeMap(roomInfo.gametypeList);
const gameList = roomInfo.gameList.map(
(game) => normalizeGameObject(game, gametypeMap),
);
const games: { [gameId: number]: Enriched.Game } = {};
for (const rawGame of roomInfo.gameList) {
const normalized = normalizeGameObject(rawGame, gametypeMap);
games[normalized.info.gameId] = normalized;
}
const users: { [userName: string]: Data.ServerInfo_User } = {};
for (const user of roomInfo.userList) {
users[user.name] = user;
}
return {
...roomInfo,
info: roomInfo,
gametypeMap,
gameList,
order: 0,
games,
users,
};
}
/** Flatten gameTypes[] into a gameType string; fill in default sortable values. */
/** Wrap a raw ServerInfo_Game in the composition shape with cached gameType. */
export function normalizeGameObject(game: Data.ServerInfo_Game, gametypeMap: Enriched.GametypeMap): Enriched.Game {
const { gameTypes, description } = game;
const { gameTypes } = game;
const hasType = gameTypes && gameTypes.length;
return {
...game,
gameType: hasType ? gametypeMap[gameTypes[0]] : '',
description: description || '',
info: game,
gameType: hasType ? (gametypeMap[gameTypes[0]] ?? '') : '',
};
}

View file

@ -1,7 +1,7 @@
import type { MessageInitShape } from '@bufbuild/protobuf';
import { Data } from '@app/types';
import { Data, Enriched } from '@app/types';
import { create } from '@bufbuild/protobuf';
import { GameEntry, GamesState, PlayerEntry, ZoneEntry } from '../game.interfaces';
import { GamesState } from '../game.interfaces';
export function makeCard(overrides: MessageInitShape<typeof Data.ServerInfo_CardSchema> = {}): Data.ServerInfo_Card {
return create(Data.ServerInfo_CardSchema, {
@ -45,22 +45,42 @@ export function makeArrow(overrides: MessageInitShape<typeof Data.ServerInfo_Arr
startCardId: 1,
targetPlayerId: 1,
targetZone: 'table',
targetCardId: 2,
arrowColor: create(Data.colorSchema, { r: 255, g: 0, b: 0, a: 255 }),
targetCardId: 2,
...overrides,
});
}
export function makeZoneEntry(overrides: Partial<ZoneEntry> = {}): ZoneEntry {
type ZoneEntryOverrides = Partial<Enriched.ZoneEntry> & {
/**
* Convenience for tests: pass an ordered card array and the fixture
* materializes it into `{ order, byId }`. If provided, takes precedence
* over an explicit `order`/`byId` in the same overrides object.
*/
cards?: Data.ServerInfo_Card[];
};
export function makeZoneEntry(overrides: ZoneEntryOverrides = {}): Enriched.ZoneEntry {
const { cards, order, byId, ...rest } = overrides;
let resolvedOrder: number[] = order ?? [];
let resolvedById: { [id: number]: Data.ServerInfo_Card } = byId ?? {};
if (cards !== undefined) {
resolvedOrder = cards.map(c => c.id);
resolvedById = {};
for (const c of cards) {
resolvedById[c.id] = c;
}
}
return {
name: 'hand',
type: 1,
withCoords: false,
cardCount: 0,
cards: [],
order: resolvedOrder,
byId: resolvedById,
alwaysRevealTopCard: false,
alwaysLookAtTopCard: false,
...overrides,
...rest,
};
}
@ -80,7 +100,7 @@ export function makePlayerProperties(
});
}
export function makePlayerEntry(overrides: Partial<PlayerEntry> = {}): PlayerEntry {
export function makePlayerEntry(overrides: Partial<Enriched.PlayerEntry> = {}): Enriched.PlayerEntry {
return {
properties: makePlayerProperties(),
deckList: '',
@ -94,11 +114,22 @@ export function makePlayerEntry(overrides: Partial<PlayerEntry> = {}): PlayerEnt
};
}
export function makeGameEntry(overrides: Partial<GameEntry> = {}): GameEntry {
return {
export function makeGameInfo(
overrides: MessageInitShape<typeof Data.ServerInfo_GameSchema> = {},
): Data.ServerInfo_Game {
return create(Data.ServerInfo_GameSchema, {
gameId: 1,
roomId: 1,
description: 'Test Game',
gameTypes: [],
started: false,
...overrides,
});
}
export function makeGameEntry(overrides: Partial<Enriched.GameEntry> = {}): Enriched.GameEntry {
return {
info: makeGameInfo(),
hostId: 1,
localPlayerId: 1,
spectator: false,

View file

@ -1,7 +1,6 @@
import { create } from '@bufbuild/protobuf';
import { Data } from '@app/types';
import { Actions } from './game.actions';
import { Types } from './game.types';
import {
makeArrow,
makeCard,
@ -11,167 +10,189 @@ import {
describe('Actions', () => {
it('clearStore', () => {
expect(Actions.clearStore()).toEqual({ type: Types.CLEAR_STORE });
const action = Actions.clearStore();
expect(action.type).toBe('games/clearStore');
});
it('gameJoined', () => {
const data = create(Data.Event_GameJoinedSchema, { hostId: 1, playerId: 2 });
expect(Actions.gameJoined(data)).toEqual({ type: Types.GAME_JOINED, data });
const action = Actions.gameJoined({ data });
expect(action.payload.data).toBe(data);
});
it('gameLeft', () => {
expect(Actions.gameLeft(2)).toEqual({ type: Types.GAME_LEFT, gameId: 2 });
const action = Actions.gameLeft({ gameId: 2 });
expect(action.payload.gameId).toBe(2);
});
it('gameClosed', () => {
expect(Actions.gameClosed(3)).toEqual({ type: Types.GAME_CLOSED, gameId: 3 });
const action = Actions.gameClosed({ gameId: 3 });
expect(action.payload.gameId).toBe(3);
});
it('gameHostChanged', () => {
expect(Actions.gameHostChanged(1, 7)).toEqual({ type: Types.GAME_HOST_CHANGED, gameId: 1, hostId: 7 });
const action = Actions.gameHostChanged({ gameId: 1, hostId: 7 });
expect(action.payload).toEqual({ gameId: 1, hostId: 7 });
});
it('gameStateChanged', () => {
const data = create(Data.Event_GameStateChangedSchema, {
playerList: [], gameStarted: true, activePlayerId: 1, activePhase: 0, secondsElapsed: 0
});
expect(Actions.gameStateChanged(1, data)).toEqual({ type: Types.GAME_STATE_CHANGED, gameId: 1, data });
const action = Actions.gameStateChanged({ gameId: 1, data });
expect(action.payload).toEqual({ gameId: 1, data });
});
it('playerJoined', () => {
const props = makePlayerProperties();
expect(Actions.playerJoined(1, props)).toEqual({ type: Types.PLAYER_JOINED, gameId: 1, playerProperties: props });
const action = Actions.playerJoined({ gameId: 1, playerProperties: props });
expect(action.payload.playerProperties).toBe(props);
});
it('playerLeft', () => {
expect(Actions.playerLeft(1, 2, 3)).toEqual({ type: Types.PLAYER_LEFT, gameId: 1, playerId: 2, reason: 3 });
const action = Actions.playerLeft({ gameId: 1, playerId: 2 });
expect(action.payload).toEqual({ gameId: 1, playerId: 2 });
});
it('playerPropertiesChanged', () => {
const props = makePlayerProperties();
expect(Actions.playerPropertiesChanged(1, 2, props)).toEqual({
type: Types.PLAYER_PROPERTIES_CHANGED,
gameId: 1,
playerId: 2,
properties: props,
});
const action = Actions.playerPropertiesChanged({ gameId: 1, playerId: 2, properties: props });
expect(action.payload.properties).toBe(props);
});
it('kicked', () => {
expect(Actions.kicked(1)).toEqual({ type: Types.KICKED, gameId: 1 });
const action = Actions.kicked({ gameId: 1 });
expect(action.payload.gameId).toBe(1);
});
it('cardMoved', () => {
const data = create(Data.Event_MoveCardSchema, { cardId: 1 });
expect(Actions.cardMoved(1, 2, data)).toEqual({ type: Types.CARD_MOVED, gameId: 1, playerId: 2, data });
const action = Actions.cardMoved({ gameId: 1, playerId: 2, data });
expect(action.payload).toEqual({ gameId: 1, playerId: 2, data });
});
it('cardFlipped', () => {
const data = create(Data.Event_FlipCardSchema, { cardId: 1 });
expect(Actions.cardFlipped(1, 2, data)).toEqual({ type: Types.CARD_FLIPPED, gameId: 1, playerId: 2, data });
const action = Actions.cardFlipped({ gameId: 1, playerId: 2, data });
expect(action.payload.data).toBe(data);
});
it('cardDestroyed', () => {
const data = create(Data.Event_DestroyCardSchema, { cardId: 1 });
expect(Actions.cardDestroyed(1, 2, data)).toEqual({ type: Types.CARD_DESTROYED, gameId: 1, playerId: 2, data });
const action = Actions.cardDestroyed({ gameId: 1, playerId: 2, data });
expect(action.payload.data).toBe(data);
});
it('cardAttached', () => {
const data = create(Data.Event_AttachCardSchema, { cardId: 1 });
expect(Actions.cardAttached(1, 2, data)).toEqual({ type: Types.CARD_ATTACHED, gameId: 1, playerId: 2, data });
const action = Actions.cardAttached({ gameId: 1, playerId: 2, data });
expect(action.payload.data).toBe(data);
});
it('tokenCreated', () => {
const data = create(Data.Event_CreateTokenSchema, { cardId: 1 });
expect(Actions.tokenCreated(1, 2, data)).toEqual({ type: Types.TOKEN_CREATED, gameId: 1, playerId: 2, data });
const action = Actions.tokenCreated({ gameId: 1, playerId: 2, data });
expect(action.payload.data).toBe(data);
});
it('cardAttrChanged', () => {
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 });
const action = Actions.cardAttrChanged({ gameId: 1, playerId: 2, data });
expect(action.payload.data).toBe(data);
});
it('cardCounterChanged', () => {
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 });
const action = Actions.cardCounterChanged({ gameId: 1, playerId: 2, data });
expect(action.payload.data).toBe(data);
});
it('arrowCreated', () => {
const arrow = makeArrow();
const data = create(Data.Event_CreateArrowSchema, { arrowInfo: arrow });
expect(Actions.arrowCreated(1, 2, data)).toEqual({ type: Types.ARROW_CREATED, gameId: 1, playerId: 2, data });
const action = Actions.arrowCreated({ gameId: 1, playerId: 2, data });
expect(action.payload.data).toBe(data);
});
it('arrowDeleted', () => {
const data = create(Data.Event_DeleteArrowSchema, { arrowId: 3 });
expect(Actions.arrowDeleted(1, 2, data)).toEqual({ type: Types.ARROW_DELETED, gameId: 1, playerId: 2, data });
const action = Actions.arrowDeleted({ gameId: 1, playerId: 2, data });
expect(action.payload.data).toBe(data);
});
it('counterCreated', () => {
const counter = makeCounter();
const data = create(Data.Event_CreateCounterSchema, { counterInfo: counter });
expect(Actions.counterCreated(1, 2, data)).toEqual({ type: Types.COUNTER_CREATED, gameId: 1, playerId: 2, data });
const action = Actions.counterCreated({ gameId: 1, playerId: 2, data });
expect(action.payload.data).toBe(data);
});
it('counterSet', () => {
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 });
const action = Actions.counterSet({ gameId: 1, playerId: 2, data });
expect(action.payload.data).toBe(data);
});
it('counterDeleted', () => {
const data = create(Data.Event_DelCounterSchema, { counterId: 1 });
expect(Actions.counterDeleted(1, 2, data)).toEqual({ type: Types.COUNTER_DELETED, gameId: 1, playerId: 2, data });
const action = Actions.counterDeleted({ gameId: 1, playerId: 2, data });
expect(action.payload.data).toBe(data);
});
it('cardsDrawn', () => {
const card = makeCard();
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 });
const action = Actions.cardsDrawn({ gameId: 1, playerId: 2, data });
expect(action.payload.data).toBe(data);
});
it('cardsRevealed', () => {
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 });
const action = Actions.cardsRevealed({ gameId: 1, playerId: 2, data });
expect(action.payload.data).toBe(data);
});
it('zoneShuffled', () => {
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 });
const action = Actions.zoneShuffled({ gameId: 1, playerId: 2, data });
expect(action.payload.data).toBe(data);
});
it('dieRolled', () => {
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 });
const action = Actions.dieRolled({ gameId: 1, playerId: 2, data });
expect(action.payload.data).toBe(data);
});
it('activePlayerSet', () => {
expect(Actions.activePlayerSet(1, 3)).toEqual({ type: Types.ACTIVE_PLAYER_SET, gameId: 1, activePlayerId: 3 });
const action = Actions.activePlayerSet({ gameId: 1, activePlayerId: 3 });
expect(action.payload).toEqual({ gameId: 1, activePlayerId: 3 });
});
it('activePhaseSet', () => {
expect(Actions.activePhaseSet(1, 2)).toEqual({ type: Types.ACTIVE_PHASE_SET, gameId: 1, phase: 2 });
const action = Actions.activePhaseSet({ gameId: 1, phase: 2 });
expect(action.payload).toEqual({ gameId: 1, phase: 2 });
});
it('turnReversed', () => {
expect(Actions.turnReversed(1, true)).toEqual({ type: Types.TURN_REVERSED, gameId: 1, reversed: true });
const action = Actions.turnReversed({ gameId: 1, reversed: true });
expect(action.payload).toEqual({ gameId: 1, reversed: true });
});
it('zoneDumped', () => {
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 });
const action = Actions.zoneDumped({ gameId: 1, playerId: 2, data });
expect(action.payload.data).toBe(data);
});
it('zonePropertiesChanged', () => {
const data = create(Data.Event_ChangeZonePropertiesSchema, { zoneName: 'deck', alwaysRevealTopCard: true, alwaysLookAtTopCard: false });
expect(Actions.zonePropertiesChanged(1, 2, data)).toEqual({
type: Types.ZONE_PROPERTIES_CHANGED,
gameId: 1,
playerId: 2,
data,
});
const action = Actions.zonePropertiesChanged({ gameId: 1, playerId: 2, data });
expect(action.payload.data).toBe(data);
});
it('gameSay', () => {
expect(Actions.gameSay(1, 2, 'hello')).toEqual({ type: Types.GAME_SAY, gameId: 1, playerId: 2, message: 'hello' });
const action = Actions.gameSay({ gameId: 1, playerId: 2, message: 'hello' });
expect(action.payload).toEqual({ gameId: 1, playerId: 2, message: 'hello' });
});
});

View file

@ -1,213 +1,5 @@
import type { Data } from '@app/types';
import { Types } from './game.types';
import { gamesSlice } from './game.reducer';
export const Actions = {
clearStore: () => ({
type: Types.CLEAR_STORE,
}),
gameJoined: (data: Data.Event_GameJoined) => ({
type: Types.GAME_JOINED,
data,
}),
gameLeft: (gameId: number) => ({
type: Types.GAME_LEFT,
gameId,
}),
gameClosed: (gameId: number) => ({
type: Types.GAME_CLOSED,
gameId,
}),
gameHostChanged: (gameId: number, hostId: number) => ({
type: Types.GAME_HOST_CHANGED,
gameId,
hostId,
}),
gameStateChanged: (gameId: number, data: Data.Event_GameStateChanged) => ({
type: Types.GAME_STATE_CHANGED,
gameId,
data,
}),
playerJoined: (gameId: number, playerProperties: Data.ServerInfo_PlayerProperties) => ({
type: Types.PLAYER_JOINED,
gameId,
playerProperties,
}),
playerLeft: (gameId: number, playerId: number, reason: number) => ({
type: Types.PLAYER_LEFT,
gameId,
playerId,
reason,
}),
playerPropertiesChanged: (gameId: number, playerId: number, properties: Data.ServerInfo_PlayerProperties) => ({
type: Types.PLAYER_PROPERTIES_CHANGED,
gameId,
playerId,
properties,
}),
kicked: (gameId: number) => ({
type: Types.KICKED,
gameId,
}),
cardMoved: (gameId: number, playerId: number, data: Data.Event_MoveCard) => ({
type: Types.CARD_MOVED,
gameId,
playerId,
data,
}),
cardFlipped: (gameId: number, playerId: number, data: Data.Event_FlipCard) => ({
type: Types.CARD_FLIPPED,
gameId,
playerId,
data,
}),
cardDestroyed: (gameId: number, playerId: number, data: Data.Event_DestroyCard) => ({
type: Types.CARD_DESTROYED,
gameId,
playerId,
data,
}),
cardAttached: (gameId: number, playerId: number, data: Data.Event_AttachCard) => ({
type: Types.CARD_ATTACHED,
gameId,
playerId,
data,
}),
tokenCreated: (gameId: number, playerId: number, data: Data.Event_CreateToken) => ({
type: Types.TOKEN_CREATED,
gameId,
playerId,
data,
}),
cardAttrChanged: (gameId: number, playerId: number, data: Data.Event_SetCardAttr) => ({
type: Types.CARD_ATTR_CHANGED,
gameId,
playerId,
data,
}),
cardCounterChanged: (gameId: number, playerId: number, data: Data.Event_SetCardCounter) => ({
type: Types.CARD_COUNTER_CHANGED,
gameId,
playerId,
data,
}),
arrowCreated: (gameId: number, playerId: number, data: Data.Event_CreateArrow) => ({
type: Types.ARROW_CREATED,
gameId,
playerId,
data,
}),
arrowDeleted: (gameId: number, playerId: number, data: Data.Event_DeleteArrow) => ({
type: Types.ARROW_DELETED,
gameId,
playerId,
data,
}),
counterCreated: (gameId: number, playerId: number, data: Data.Event_CreateCounter) => ({
type: Types.COUNTER_CREATED,
gameId,
playerId,
data,
}),
counterSet: (gameId: number, playerId: number, data: Data.Event_SetCounter) => ({
type: Types.COUNTER_SET,
gameId,
playerId,
data,
}),
counterDeleted: (gameId: number, playerId: number, data: Data.Event_DelCounter) => ({
type: Types.COUNTER_DELETED,
gameId,
playerId,
data,
}),
cardsDrawn: (gameId: number, playerId: number, data: Data.Event_DrawCards) => ({
type: Types.CARDS_DRAWN,
gameId,
playerId,
data,
}),
cardsRevealed: (gameId: number, playerId: number, data: Data.Event_RevealCards) => ({
type: Types.CARDS_REVEALED,
gameId,
playerId,
data,
}),
zoneShuffled: (gameId: number, playerId: number, data: Data.Event_Shuffle) => ({
type: Types.ZONE_SHUFFLED,
gameId,
playerId,
data,
}),
dieRolled: (gameId: number, playerId: number, data: Data.Event_RollDie) => ({
type: Types.DIE_ROLLED,
gameId,
playerId,
data,
}),
activePlayerSet: (gameId: number, activePlayerId: number) => ({
type: Types.ACTIVE_PLAYER_SET,
gameId,
activePlayerId,
}),
activePhaseSet: (gameId: number, phase: number) => ({
type: Types.ACTIVE_PHASE_SET,
gameId,
phase,
}),
turnReversed: (gameId: number, reversed: boolean) => ({
type: Types.TURN_REVERSED,
gameId,
reversed,
}),
zoneDumped: (gameId: number, playerId: number, data: Data.Event_DumpZone) => ({
type: Types.ZONE_DUMPED,
gameId,
playerId,
data,
}),
zonePropertiesChanged: (gameId: number, playerId: number, data: Data.Event_ChangeZoneProperties) => ({
type: Types.ZONE_PROPERTIES_CHANGED,
gameId,
playerId,
data,
}),
gameSay: (gameId: number, playerId: number, message: string) => ({
type: Types.GAME_SAY,
gameId,
playerId,
message,
}),
};
export const Actions = gamesSlice.actions;
export type GameAction = ReturnType<typeof Actions[keyof typeof Actions]>;

View file

@ -1,6 +1,3 @@
// Use `vi.hoisted` so the mocked `store.dispatch` reference stays stable across
// re-runs of the factory under `isolate: false`. See rooms.dispatch.spec.ts for
// the same pattern and rationale.
const { mockDispatch } = vi.hoisted(() => ({ mockDispatch: vi.fn() }));
vi.mock('../store', () => ({ store: { dispatch: mockDispatch } }));
@ -28,22 +25,22 @@ describe('Dispatch', () => {
it('gameJoined dispatches Actions.gameJoined()', () => {
const data = create(Data.Event_GameJoinedSchema, { hostId: 1, playerId: 2 });
Dispatch.gameJoined(data);
expect(mockDispatch).toHaveBeenCalledWith(Actions.gameJoined(data));
expect(mockDispatch).toHaveBeenCalledWith(Actions.gameJoined({ data }));
});
it('gameLeft dispatches Actions.gameLeft()', () => {
Dispatch.gameLeft(2);
expect(mockDispatch).toHaveBeenCalledWith(Actions.gameLeft(2));
expect(mockDispatch).toHaveBeenCalledWith(Actions.gameLeft({ gameId: 2 }));
});
it('gameClosed dispatches Actions.gameClosed()', () => {
Dispatch.gameClosed(3);
expect(mockDispatch).toHaveBeenCalledWith(Actions.gameClosed(3));
expect(mockDispatch).toHaveBeenCalledWith(Actions.gameClosed({ gameId: 3 }));
});
it('gameHostChanged dispatches Actions.gameHostChanged()', () => {
Dispatch.gameHostChanged(1, 7);
expect(mockDispatch).toHaveBeenCalledWith(Actions.gameHostChanged(1, 7));
expect(mockDispatch).toHaveBeenCalledWith(Actions.gameHostChanged({ gameId: 1, hostId: 7 }));
});
it('gameStateChanged dispatches Actions.gameStateChanged()', () => {
@ -51,156 +48,156 @@ describe('Dispatch', () => {
playerList: [], gameStarted: false, activePlayerId: 0, activePhase: 0, secondsElapsed: 0
});
Dispatch.gameStateChanged(1, data);
expect(mockDispatch).toHaveBeenCalledWith(Actions.gameStateChanged(1, data));
expect(mockDispatch).toHaveBeenCalledWith(Actions.gameStateChanged({ gameId: 1, data }));
});
it('playerJoined dispatches Actions.playerJoined()', () => {
const props = makePlayerProperties();
Dispatch.playerJoined(1, props);
expect(mockDispatch).toHaveBeenCalledWith(Actions.playerJoined(1, props));
expect(mockDispatch).toHaveBeenCalledWith(Actions.playerJoined({ gameId: 1, playerProperties: props }));
});
it('playerLeft dispatches Actions.playerLeft()', () => {
Dispatch.playerLeft(1, 2, 3);
expect(mockDispatch).toHaveBeenCalledWith(Actions.playerLeft(1, 2, 3));
expect(mockDispatch).toHaveBeenCalledWith(Actions.playerLeft({ gameId: 1, playerId: 2 }));
});
it('playerPropertiesChanged dispatches Actions.playerPropertiesChanged()', () => {
const props = makePlayerProperties();
Dispatch.playerPropertiesChanged(1, 2, props);
expect(mockDispatch).toHaveBeenCalledWith(Actions.playerPropertiesChanged(1, 2, props));
expect(mockDispatch).toHaveBeenCalledWith(Actions.playerPropertiesChanged({ gameId: 1, playerId: 2, properties: props }));
});
it('kicked dispatches Actions.kicked()', () => {
Dispatch.kicked(1);
expect(mockDispatch).toHaveBeenCalledWith(Actions.kicked(1));
expect(mockDispatch).toHaveBeenCalledWith(Actions.kicked({ gameId: 1 }));
});
it('cardMoved dispatches Actions.cardMoved()', () => {
const data = create(Data.Event_MoveCardSchema, { cardId: 1 });
Dispatch.cardMoved(1, 2, data);
expect(mockDispatch).toHaveBeenCalledWith(Actions.cardMoved(1, 2, data));
expect(mockDispatch).toHaveBeenCalledWith(Actions.cardMoved({ gameId: 1, playerId: 2, data }));
});
it('cardFlipped dispatches Actions.cardFlipped()', () => {
const data = create(Data.Event_FlipCardSchema, { cardId: 1 });
Dispatch.cardFlipped(1, 2, data);
expect(mockDispatch).toHaveBeenCalledWith(Actions.cardFlipped(1, 2, data));
expect(mockDispatch).toHaveBeenCalledWith(Actions.cardFlipped({ gameId: 1, playerId: 2, data }));
});
it('cardDestroyed dispatches Actions.cardDestroyed()', () => {
const data = create(Data.Event_DestroyCardSchema, { cardId: 1 });
Dispatch.cardDestroyed(1, 2, data);
expect(mockDispatch).toHaveBeenCalledWith(Actions.cardDestroyed(1, 2, data));
expect(mockDispatch).toHaveBeenCalledWith(Actions.cardDestroyed({ gameId: 1, playerId: 2, data }));
});
it('cardAttached dispatches Actions.cardAttached()', () => {
const data = create(Data.Event_AttachCardSchema, { cardId: 1 });
Dispatch.cardAttached(1, 2, data);
expect(mockDispatch).toHaveBeenCalledWith(Actions.cardAttached(1, 2, data));
expect(mockDispatch).toHaveBeenCalledWith(Actions.cardAttached({ gameId: 1, playerId: 2, data }));
});
it('tokenCreated dispatches Actions.tokenCreated()', () => {
const data = create(Data.Event_CreateTokenSchema, { cardId: 1 });
Dispatch.tokenCreated(1, 2, data);
expect(mockDispatch).toHaveBeenCalledWith(Actions.tokenCreated(1, 2, data));
expect(mockDispatch).toHaveBeenCalledWith(Actions.tokenCreated({ gameId: 1, playerId: 2, data }));
});
it('cardAttrChanged dispatches Actions.cardAttrChanged()', () => {
const data = create(Data.Event_SetCardAttrSchema, { cardId: 1 });
Dispatch.cardAttrChanged(1, 2, data);
expect(mockDispatch).toHaveBeenCalledWith(Actions.cardAttrChanged(1, 2, data));
expect(mockDispatch).toHaveBeenCalledWith(Actions.cardAttrChanged({ gameId: 1, playerId: 2, data }));
});
it('cardCounterChanged dispatches Actions.cardCounterChanged()', () => {
const data = create(Data.Event_SetCardCounterSchema, { cardId: 1 });
Dispatch.cardCounterChanged(1, 2, data);
expect(mockDispatch).toHaveBeenCalledWith(Actions.cardCounterChanged(1, 2, data));
expect(mockDispatch).toHaveBeenCalledWith(Actions.cardCounterChanged({ gameId: 1, playerId: 2, data }));
});
it('arrowCreated dispatches Actions.arrowCreated()', () => {
const data = create(Data.Event_CreateArrowSchema, { arrowInfo: makeArrow() });
Dispatch.arrowCreated(1, 2, data);
expect(mockDispatch).toHaveBeenCalledWith(Actions.arrowCreated(1, 2, data));
expect(mockDispatch).toHaveBeenCalledWith(Actions.arrowCreated({ gameId: 1, playerId: 2, data }));
});
it('arrowDeleted dispatches Actions.arrowDeleted()', () => {
const data = create(Data.Event_DeleteArrowSchema, { arrowId: 3 });
Dispatch.arrowDeleted(1, 2, data);
expect(mockDispatch).toHaveBeenCalledWith(Actions.arrowDeleted(1, 2, data));
expect(mockDispatch).toHaveBeenCalledWith(Actions.arrowDeleted({ gameId: 1, playerId: 2, data }));
});
it('counterCreated dispatches Actions.counterCreated()', () => {
const data = create(Data.Event_CreateCounterSchema, { counterInfo: makeCounter() });
Dispatch.counterCreated(1, 2, data);
expect(mockDispatch).toHaveBeenCalledWith(Actions.counterCreated(1, 2, data));
expect(mockDispatch).toHaveBeenCalledWith(Actions.counterCreated({ gameId: 1, playerId: 2, data }));
});
it('counterSet dispatches Actions.counterSet()', () => {
const data = create(Data.Event_SetCounterSchema, { counterId: 1, value: 10 });
Dispatch.counterSet(1, 2, data);
expect(mockDispatch).toHaveBeenCalledWith(Actions.counterSet(1, 2, data));
expect(mockDispatch).toHaveBeenCalledWith(Actions.counterSet({ gameId: 1, playerId: 2, data }));
});
it('counterDeleted dispatches Actions.counterDeleted()', () => {
const data = create(Data.Event_DelCounterSchema, { counterId: 1 });
Dispatch.counterDeleted(1, 2, data);
expect(mockDispatch).toHaveBeenCalledWith(Actions.counterDeleted(1, 2, data));
expect(mockDispatch).toHaveBeenCalledWith(Actions.counterDeleted({ gameId: 1, playerId: 2, data }));
});
it('cardsDrawn dispatches Actions.cardsDrawn()', () => {
const data = create(Data.Event_DrawCardsSchema, { number: 2, cards: [makeCard()] });
Dispatch.cardsDrawn(1, 2, data);
expect(mockDispatch).toHaveBeenCalledWith(Actions.cardsDrawn(1, 2, data));
expect(mockDispatch).toHaveBeenCalledWith(Actions.cardsDrawn({ gameId: 1, playerId: 2, data }));
});
it('cardsRevealed dispatches Actions.cardsRevealed()', () => {
const data = create(Data.Event_RevealCardsSchema, { zoneName: 'hand', cards: [] });
Dispatch.cardsRevealed(1, 2, data);
expect(mockDispatch).toHaveBeenCalledWith(Actions.cardsRevealed(1, 2, data));
expect(mockDispatch).toHaveBeenCalledWith(Actions.cardsRevealed({ gameId: 1, playerId: 2, data }));
});
it('zoneShuffled dispatches Actions.zoneShuffled()', () => {
const data = create(Data.Event_ShuffleSchema, { zoneName: 'deck', start: 0, end: 39 });
Dispatch.zoneShuffled(1, 2, data);
expect(mockDispatch).toHaveBeenCalledWith(Actions.zoneShuffled(1, 2, data));
expect(mockDispatch).toHaveBeenCalledWith(Actions.zoneShuffled({ gameId: 1, playerId: 2, data }));
});
it('dieRolled dispatches Actions.dieRolled()', () => {
const data = create(Data.Event_RollDieSchema, { sides: 6, value: 4, values: [4] });
Dispatch.dieRolled(1, 2, data);
expect(mockDispatch).toHaveBeenCalledWith(Actions.dieRolled(1, 2, data));
expect(mockDispatch).toHaveBeenCalledWith(Actions.dieRolled({ gameId: 1, playerId: 2, data }));
});
it('activePlayerSet dispatches Actions.activePlayerSet()', () => {
Dispatch.activePlayerSet(1, 3);
expect(mockDispatch).toHaveBeenCalledWith(Actions.activePlayerSet(1, 3));
expect(mockDispatch).toHaveBeenCalledWith(Actions.activePlayerSet({ gameId: 1, activePlayerId: 3 }));
});
it('activePhaseSet dispatches Actions.activePhaseSet()', () => {
Dispatch.activePhaseSet(1, 2);
expect(mockDispatch).toHaveBeenCalledWith(Actions.activePhaseSet(1, 2));
expect(mockDispatch).toHaveBeenCalledWith(Actions.activePhaseSet({ gameId: 1, phase: 2 }));
});
it('turnReversed dispatches Actions.turnReversed()', () => {
Dispatch.turnReversed(1, true);
expect(mockDispatch).toHaveBeenCalledWith(Actions.turnReversed(1, true));
expect(mockDispatch).toHaveBeenCalledWith(Actions.turnReversed({ gameId: 1, reversed: true }));
});
it('zoneDumped dispatches Actions.zoneDumped()', () => {
const data = create(Data.Event_DumpZoneSchema, { zoneOwnerId: 1, zoneName: 'hand', numberCards: 3, isReversed: false });
Dispatch.zoneDumped(1, 2, data);
expect(mockDispatch).toHaveBeenCalledWith(Actions.zoneDumped(1, 2, data));
expect(mockDispatch).toHaveBeenCalledWith(Actions.zoneDumped({ gameId: 1, playerId: 2, data }));
});
it('zonePropertiesChanged dispatches Actions.zonePropertiesChanged()', () => {
const data = create(Data.Event_ChangeZonePropertiesSchema, { zoneName: 'deck', alwaysRevealTopCard: true, alwaysLookAtTopCard: false });
Dispatch.zonePropertiesChanged(1, 2, data);
expect(mockDispatch).toHaveBeenCalledWith(Actions.zonePropertiesChanged(1, 2, data));
expect(mockDispatch).toHaveBeenCalledWith(Actions.zonePropertiesChanged({ gameId: 1, playerId: 2, data }));
});
it('gameSay dispatches Actions.gameSay()', () => {
Dispatch.gameSay(1, 2, 'gg wp');
expect(mockDispatch).toHaveBeenCalledWith(Actions.gameSay(1, 2, 'gg wp'));
expect(mockDispatch).toHaveBeenCalledWith(Actions.gameSay({ gameId: 1, playerId: 2, message: 'gg wp' }));
});
});

View file

@ -8,126 +8,126 @@ export const Dispatch = {
},
gameJoined: (data: Data.Event_GameJoined) => {
store.dispatch(Actions.gameJoined(data));
store.dispatch(Actions.gameJoined({ data }));
},
gameLeft: (gameId: number) => {
store.dispatch(Actions.gameLeft(gameId));
store.dispatch(Actions.gameLeft({ gameId }));
},
gameClosed: (gameId: number) => {
store.dispatch(Actions.gameClosed(gameId));
store.dispatch(Actions.gameClosed({ gameId }));
},
gameHostChanged: (gameId: number, hostId: number) => {
store.dispatch(Actions.gameHostChanged(gameId, hostId));
store.dispatch(Actions.gameHostChanged({ gameId, hostId }));
},
gameStateChanged: (gameId: number, data: Data.Event_GameStateChanged) => {
store.dispatch(Actions.gameStateChanged(gameId, data));
store.dispatch(Actions.gameStateChanged({ gameId, data }));
},
playerJoined: (gameId: number, playerProperties: Data.ServerInfo_PlayerProperties) => {
store.dispatch(Actions.playerJoined(gameId, playerProperties));
store.dispatch(Actions.playerJoined({ gameId, playerProperties }));
},
playerLeft: (gameId: number, playerId: number, reason: number) => {
store.dispatch(Actions.playerLeft(gameId, playerId, reason));
playerLeft: (gameId: number, playerId: number, _reason: number) => {
store.dispatch(Actions.playerLeft({ gameId, playerId }));
},
playerPropertiesChanged: (gameId: number, playerId: number, properties: Data.ServerInfo_PlayerProperties) => {
store.dispatch(Actions.playerPropertiesChanged(gameId, playerId, properties));
store.dispatch(Actions.playerPropertiesChanged({ gameId, playerId, properties }));
},
kicked: (gameId: number) => {
store.dispatch(Actions.kicked(gameId));
store.dispatch(Actions.kicked({ gameId }));
},
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: Data.Event_FlipCard) => {
store.dispatch(Actions.cardFlipped(gameId, playerId, data));
store.dispatch(Actions.cardFlipped({ gameId, playerId, data }));
},
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: Data.Event_AttachCard) => {
store.dispatch(Actions.cardAttached(gameId, playerId, data));
store.dispatch(Actions.cardAttached({ gameId, playerId, data }));
},
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: Data.Event_SetCardAttr) => {
store.dispatch(Actions.cardAttrChanged(gameId, playerId, data));
store.dispatch(Actions.cardAttrChanged({ gameId, playerId, data }));
},
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: Data.Event_CreateArrow) => {
store.dispatch(Actions.arrowCreated(gameId, playerId, data));
store.dispatch(Actions.arrowCreated({ gameId, playerId, data }));
},
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: Data.Event_CreateCounter) => {
store.dispatch(Actions.counterCreated(gameId, playerId, data));
store.dispatch(Actions.counterCreated({ gameId, playerId, data }));
},
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: Data.Event_DelCounter) => {
store.dispatch(Actions.counterDeleted(gameId, playerId, data));
store.dispatch(Actions.counterDeleted({ gameId, playerId, data }));
},
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: Data.Event_RevealCards) => {
store.dispatch(Actions.cardsRevealed(gameId, playerId, data));
store.dispatch(Actions.cardsRevealed({ gameId, playerId, data }));
},
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: Data.Event_RollDie) => {
store.dispatch(Actions.dieRolled(gameId, playerId, data));
store.dispatch(Actions.dieRolled({ gameId, playerId, data }));
},
activePlayerSet: (gameId: number, activePlayerId: number) => {
store.dispatch(Actions.activePlayerSet(gameId, activePlayerId));
store.dispatch(Actions.activePlayerSet({ gameId, activePlayerId }));
},
activePhaseSet: (gameId: number, phase: number) => {
store.dispatch(Actions.activePhaseSet(gameId, phase));
store.dispatch(Actions.activePhaseSet({ gameId, phase }));
},
turnReversed: (gameId: number, reversed: boolean) => {
store.dispatch(Actions.turnReversed(gameId, reversed));
store.dispatch(Actions.turnReversed({ gameId, reversed }));
},
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: Data.Event_ChangeZoneProperties) => {
store.dispatch(Actions.zonePropertiesChanged(gameId, playerId, data));
store.dispatch(Actions.zonePropertiesChanged({ gameId, playerId, data }));
},
gameSay: (gameId: number, playerId: number, message: string) => {
store.dispatch(Actions.gameSay(gameId, playerId, message));
store.dispatch(Actions.gameSay({ gameId, playerId, message }));
},
};

View file

@ -1,60 +1,5 @@
import type { Data } from '@app/types';
import type { Enriched } from '@app/types';
export interface GamesState {
games: { [gameId: number]: GameEntry };
}
/**
* Full runtime state for a single active game (played or spectated).
* Keyed by gameId in GamesState so multiple concurrent games are supported.
*/
export interface GameEntry {
gameId: number;
roomId: number;
description: string;
hostId: number;
/** The playerId assigned to the local user in this game. */
localPlayerId: number;
spectator: boolean;
judge: boolean;
resuming: boolean;
started: boolean;
activePlayerId: number;
activePhase: number;
secondsElapsed: number;
reversed: boolean;
players: { [playerId: number]: PlayerEntry };
messages: GameMessage[];
}
/** Normalized from ServerInfo_Player — keyed collections for O(1) lookup. */
export interface PlayerEntry {
properties: Data.ServerInfo_PlayerProperties;
deckList: string;
/** Zones keyed by zone name (e.g. "hand", "deck", "table"). */
zones: { [zoneName: string]: ZoneEntry };
/** Player-level counters (e.g. life) keyed by counter id. */
counters: { [counterId: number]: Data.ServerInfo_Counter };
/** Arrows keyed by arrow id. */
arrows: { [arrowId: number]: Data.ServerInfo_Arrow };
}
/** Normalized from ServerInfo_Zone — card list is an ordered array matching proto. */
export interface ZoneEntry {
name: string;
/** ZoneType enum value (0=Private, 1=Public, 2=Hidden). */
type: number;
withCoords: boolean;
/** Authoritative card count (used for hidden zones where cardList may be empty). */
cardCount: number;
/** Ordered card list; may be empty for hidden zones with no dump active. */
cards: Data.ServerInfo_Card[];
alwaysRevealTopCard: boolean;
alwaysLookAtTopCard: boolean;
}
export interface GameMessage {
playerId: number;
message: string;
timeReceived: number;
games: { [gameId: number]: Enriched.GameEntry };
}

File diff suppressed because it is too large Load diff

View file

@ -1,85 +1,31 @@
import { Data } from '@app/types';
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { Data, Enriched } from '@app/types';
import { create } from '@bufbuild/protobuf';
import { GameAction } from './game.actions';
import { GameEntry, GameMessage, GamesState, PlayerEntry, ZoneEntry } from './game.interfaces';
import { Types } from './game.types';
import { GamesState } from './game.interfaces';
// ── Helpers ──────────────────────────────────────────────────────────────────
export const MAX_GAME_MESSAGES = 1000;
function updateGame(state: GamesState, gameId: number, updates: Partial<GameEntry>): GamesState {
const game = state.games[gameId];
if (!game) {
return state;
}
return {
...state,
games: { ...state.games, [gameId]: { ...game, ...updates } },
};
}
function updatePlayer(
state: GamesState,
gameId: number,
playerId: number,
updates: Partial<PlayerEntry>
): GamesState {
const game = state.games[gameId];
if (!game) {
return state;
}
const player = game.players[playerId];
if (!player) {
return state;
}
return updateGame(state, gameId, {
players: { ...game.players, [playerId]: { ...player, ...updates } },
});
}
function updateZone(
state: GamesState,
gameId: number,
playerId: number,
zoneName: string,
updates: Partial<ZoneEntry>
): GamesState {
const game = state.games[gameId];
if (!game) {
return state;
}
const player = game.players[playerId];
if (!player) {
return state;
}
const zone = player.zones[zoneName];
if (!zone) {
return state;
}
return updatePlayer(state, gameId, playerId, {
zones: { ...player.zones, [zoneName]: { ...zone, ...updates } },
});
}
function removeGame(state: GamesState, gameId: number): GamesState {
const games = { ...state.games };
delete games[gameId];
return { ...state, games };
}
/** Converts the proto PlayerInfo[] array into the keyed PlayerEntry map used in the store. */
function normalizePlayers(playerList: Data.ServerInfo_Player[]): { [playerId: number]: PlayerEntry } {
const players: { [playerId: number]: PlayerEntry } = {};
/** Converts the proto ServerInfo_Player[] array into the keyed PlayerEntry map. */
function normalizePlayers(playerList: Data.ServerInfo_Player[]): { [playerId: number]: Enriched.PlayerEntry } {
const players: { [playerId: number]: Enriched.PlayerEntry } = {};
for (const player of playerList) {
const playerId = player.properties.playerId;
const zones: { [zoneName: string]: ZoneEntry } = {};
const zones: { [zoneName: string]: Enriched.ZoneEntry } = {};
for (const zone of player.zoneList) {
const order: number[] = [];
const byId: { [id: number]: Data.ServerInfo_Card } = {};
for (const card of zone.cardList) {
order.push(card.id);
byId[card.id] = card;
}
zones[zone.name] = {
name: zone.name,
type: zone.type,
withCoords: zone.withCoords,
cardCount: zone.cardCount,
cards: [...zone.cardList],
order,
byId,
alwaysRevealTopCard: zone.alwaysRevealTopCard,
alwaysLookAtTopCard: zone.alwaysLookAtTopCard,
};
@ -115,50 +61,29 @@ function buildEmptyCard(
providerId: string
): Data.ServerInfo_Card {
return create(Data.ServerInfo_CardSchema, {
id,
name,
x,
y,
faceDown,
tapped: false,
attacking: false,
color: '',
pt: '',
annotation: '',
destroyOnZoneChange: false,
doesntUntap: false,
counterList: [],
attachPlayerId: -1,
attachZone: '',
attachCardId: -1,
providerId,
id, name, x, y, faceDown,
tapped: false, attacking: false, color: '', pt: '', annotation: '',
destroyOnZoneChange: false, doesntUntap: false, counterList: [],
attachPlayerId: -1, attachZone: '', attachCardId: -1, providerId,
});
}
// ── Initial state ─────────────────────────────────────────────────────────────
const initialState: GamesState = { games: {} };
const initialState: GamesState = {
games: {},
};
export const gamesSlice = createSlice({
name: 'games',
initialState,
reducers: {
clearStore: () => initialState,
// ── Reducer ───────────────────────────────────────────────────────────────────
export const gamesReducer = (state: GamesState = initialState, action: GameAction): GamesState => {
switch (action.type) {
case Types.CLEAR_STORE: {
return initialState;
}
case Types.GAME_JOINED: {
const { data } = action;
gameJoined: (state, action: PayloadAction<{ data: Data.Event_GameJoined }>) => {
const { data } = action.payload;
const gameInfo = data.gameInfo;
if (!gameInfo) {
return state;
return;
}
const gameEntry: GameEntry = {
gameId: gameInfo.gameId,
roomId: gameInfo.roomId,
description: gameInfo.description,
state.games[gameInfo.gameId] = {
info: gameInfo,
hostId: data.hostId,
localPlayerId: data.playerId,
spectator: data.spectator,
@ -172,574 +97,391 @@ export const gamesReducer = (state: GamesState = initialState, action: GameActio
players: {},
messages: [],
};
return {
...state,
games: { ...state.games, [gameEntry.gameId]: gameEntry },
};
}
},
case Types.GAME_LEFT:
case Types.GAME_CLOSED:
case Types.KICKED: {
return removeGame(state, action.gameId);
}
gameLeft: (state, action: PayloadAction<{ gameId: number }>) => {
delete state.games[action.payload.gameId];
},
case Types.GAME_HOST_CHANGED: {
return updateGame(state, action.gameId, { hostId: action.hostId });
}
gameClosed: (state, action: PayloadAction<{ gameId: number }>) => {
delete state.games[action.payload.gameId];
},
case Types.GAME_STATE_CHANGED: {
const { gameId, data } = action;
kicked: (state, action: PayloadAction<{ gameId: number }>) => {
delete state.games[action.payload.gameId];
},
gameHostChanged: (state, action: PayloadAction<{ gameId: number; hostId: number }>) => {
const { gameId, hostId } = action.payload;
const game = state.games[gameId];
if (game) {
game.hostId = hostId;
}
},
gameStateChanged: (state, action: PayloadAction<{ gameId: number; data: Data.Event_GameStateChanged }>) => {
const { gameId, data } = action.payload;
const game = state.games[gameId];
if (!game) {
return state;
return;
}
const updates: Partial<GameEntry> = {};
if (data.playerList?.length > 0) {
updates.players = normalizePlayers(data.playerList);
game.players = normalizePlayers(data.playerList);
}
if (data.gameStarted !== undefined && data.gameStarted !== null) {
updates.started = data.gameStarted;
game.started = data.gameStarted;
}
if (data.activePlayerId !== undefined && data.activePlayerId !== null) {
updates.activePlayerId = data.activePlayerId;
game.activePlayerId = data.activePlayerId;
}
if (data.activePhase !== undefined && data.activePhase !== null) {
updates.activePhase = data.activePhase;
game.activePhase = data.activePhase;
}
if (data.secondsElapsed !== undefined) {
updates.secondsElapsed = data.secondsElapsed;
game.secondsElapsed = data.secondsElapsed;
}
return updateGame(state, gameId, updates);
}
},
case Types.PLAYER_JOINED: {
const { gameId, playerProperties } = action;
playerJoined: (state, action: PayloadAction<{ gameId: number; playerProperties: Data.ServerInfo_PlayerProperties }>) => {
const { gameId, playerProperties } = action.payload;
const game = state.games[gameId];
if (!game) {
return state;
return;
}
const newPlayer: PlayerEntry = {
game.players[playerProperties.playerId] = {
properties: playerProperties,
deckList: '',
zones: {},
counters: {},
arrows: {},
};
return updateGame(state, gameId, {
players: { ...game.players, [playerProperties.playerId]: newPlayer },
});
}
},
case Types.PLAYER_LEFT: {
const { gameId, playerId } = action;
playerLeft: (state, action: PayloadAction<{ gameId: number; playerId: number }>) => {
const { gameId, playerId } = action.payload;
const game = state.games[gameId];
if (!game) {
return state;
if (game) {
delete game.players[playerId];
}
const players = { ...game.players };
delete players[playerId];
return updateGame(state, gameId, { players });
}
},
case Types.PLAYER_PROPERTIES_CHANGED: {
return updatePlayer(state, action.gameId, action.playerId, {
properties: action.properties,
});
}
playerPropertiesChanged: (
state,
action: PayloadAction<{ gameId: number; playerId: number; properties: Data.ServerInfo_PlayerProperties }>,
) => {
const { gameId, playerId, properties } = action.payload;
const player = state.games[gameId]?.players[playerId];
if (player) {
player.properties = properties;
}
},
// ── Card manipulation ────────────────────────────────────────────────────
case Types.CARD_MOVED: {
const { gameId, playerId, data } = action;
cardMoved: (
state,
action: PayloadAction<{ gameId: number; playerId: number; data: Data.Event_MoveCard }>,
) => {
const { gameId, playerId, data } = action.payload;
const {
cardId,
cardName,
startPlayerId,
startZone,
position,
targetPlayerId,
targetZone,
x,
y,
newCardId,
faceDown,
newCardProviderId,
cardId, cardName, startPlayerId, startZone, position,
targetPlayerId, targetZone, x, y, newCardId, faceDown, newCardProviderId,
} = data;
const game = state.games[gameId];
if (!game) {
return state;
return;
}
const effectiveStartPlayerId = startPlayerId >= 0 ? startPlayerId : playerId;
const sourcePlayer = game.players[effectiveStartPlayerId];
const sourceZoneEntry = sourcePlayer?.zones[startZone];
if (!sourcePlayer || !sourceZoneEntry) {
return state;
const sourceZone = sourcePlayer?.zones[startZone];
if (!sourcePlayer || !sourceZone) {
return;
}
// Locate card in source zone (by id for visible zones, by position for hidden)
let removedCard: Data.ServerInfo_Card | undefined;
let newSourceCards: Data.ServerInfo_Card[];
let resolvedCardId = -1;
if (cardId >= 0) {
removedCard = sourceZoneEntry.cards.find(c => c.id === cardId);
newSourceCards = sourceZoneEntry.cards.filter(c => c.id !== cardId);
} else if (position >= 0 && position < sourceZoneEntry.cards.length) {
removedCard = sourceZoneEntry.cards[position];
newSourceCards = sourceZoneEntry.cards.filter((_, i) => i !== position);
} else {
// Hidden zone with unknown position — just decrement count
newSourceCards = sourceZoneEntry.cards;
resolvedCardId = cardId;
} else if (position >= 0 && position < sourceZone.order.length) {
resolvedCardId = sourceZone.order[position];
}
const removedCard: Data.ServerInfo_Card | undefined =
resolvedCardId >= 0 ? sourceZone.byId[resolvedCardId] : undefined;
if (resolvedCardId >= 0) {
const idx = sourceZone.order.indexOf(resolvedCardId);
if (idx >= 0) {
sourceZone.order.splice(idx, 1);
}
delete sourceZone.byId[resolvedCardId];
}
sourceZone.cardCount = Math.max(0, sourceZone.cardCount - 1);
const effectiveNewId = newCardId >= 0 ? newCardId : (removedCard?.id ?? -1);
const movedCard: Data.ServerInfo_Card = removedCard
? {
...removedCard,
id: effectiveNewId,
name: cardName || removedCard.name,
x,
y,
faceDown,
providerId: newCardProviderId || removedCard.providerId,
...removedCard, id: effectiveNewId, name: cardName || removedCard.name,
x, y, faceDown, providerId: newCardProviderId || removedCard.providerId,
}
: buildEmptyCard(effectiveNewId, cardName, x, y, faceDown, newCardProviderId ?? '');
let newState = updateZone(state, gameId, effectiveStartPlayerId, startZone, {
cards: newSourceCards,
cardCount: Math.max(0, sourceZoneEntry.cardCount - 1),
});
const updatedGame = newState.games[gameId];
const targetPlayer = updatedGame?.players[targetPlayerId];
const targetPlayer = game.players[targetPlayerId];
const targetZoneEntry = targetPlayer?.zones[targetZone];
if (!targetPlayer || !targetZoneEntry) {
return newState;
return;
}
newState = updateZone(newState, gameId, targetPlayerId, targetZone, {
cards: [...targetZoneEntry.cards, movedCard],
cardCount: targetZoneEntry.cardCount + 1,
});
return newState;
}
targetZoneEntry.order.push(movedCard.id);
targetZoneEntry.byId[movedCard.id] = movedCard;
targetZoneEntry.cardCount++;
},
case Types.CARD_FLIPPED: {
const { gameId, playerId, data } = action;
cardFlipped: (state, action: PayloadAction<{ gameId: number; playerId: number; data: Data.Event_FlipCard }>) => {
const { gameId, playerId, data } = action.payload;
const { zoneName, cardId, cardName, faceDown, cardProviderId } = data;
const game = state.games[gameId];
if (!game) {
return state;
const card = state.games[gameId]?.players[playerId]?.zones[zoneName]?.byId[cardId];
if (!card) {
return;
}
const player = game.players[playerId];
if (!player) {
return state;
card.faceDown = faceDown;
if (cardName) {
card.name = cardName;
}
const zone = player.zones[zoneName];
if (!zone) {
return state;
if (cardProviderId) {
card.providerId = cardProviderId;
}
},
const cardIdx = zone.cards.findIndex(c => c.id === cardId);
if (cardIdx < 0) {
return state;
}
const updatedCards = [...zone.cards];
updatedCards[cardIdx] = {
...updatedCards[cardIdx],
faceDown,
name: cardName || updatedCards[cardIdx].name,
providerId: cardProviderId || updatedCards[cardIdx].providerId,
};
return updateZone(state, gameId, playerId, zoneName, { cards: updatedCards });
}
case Types.CARD_DESTROYED: {
const { gameId, playerId, data } = action;
cardDestroyed: (state, action: PayloadAction<{ gameId: number; playerId: number; data: Data.Event_DestroyCard }>) => {
const { gameId, playerId, data } = action.payload;
const { zoneName, cardId } = data;
const game = state.games[gameId];
if (!game) {
return state;
}
const player = game.players[playerId];
if (!player) {
return state;
}
const zone = player.zones[zoneName];
const zone = state.games[gameId]?.players[playerId]?.zones[zoneName];
if (!zone) {
return state;
return;
}
const idx = zone.order.indexOf(cardId);
if (idx >= 0) {
zone.order.splice(idx, 1);
}
delete zone.byId[cardId];
zone.cardCount = Math.max(0, zone.cardCount - 1);
},
return updateZone(state, gameId, playerId, zoneName, {
cards: zone.cards.filter(c => c.id !== cardId),
cardCount: Math.max(0, zone.cardCount - 1),
});
}
case Types.CARD_ATTACHED: {
const { gameId, playerId, data } = action;
cardAttached: (state, action: PayloadAction<{ gameId: number; playerId: number; data: Data.Event_AttachCard }>) => {
const { gameId, playerId, data } = action.payload;
const { startZone, cardId, targetPlayerId, targetZone, targetCardId } = data;
const game = state.games[gameId];
if (!game) {
return state;
const card = state.games[gameId]?.players[playerId]?.zones[startZone]?.byId[cardId];
if (!card) {
return;
}
const player = game.players[playerId];
if (!player) {
return state;
}
const zone = player.zones[startZone];
card.attachPlayerId = targetPlayerId;
card.attachZone = targetZone;
card.attachCardId = targetCardId;
},
tokenCreated: (state, action: PayloadAction<{ gameId: number; playerId: number; data: Data.Event_CreateToken }>) => {
const { gameId, playerId, data } = action.payload;
const { zoneName, cardId, cardName, color, pt, annotation, destroyOnZoneChange, x, y, cardProviderId, faceDown } = data;
const zone = state.games[gameId]?.players[playerId]?.zones[zoneName];
if (!zone) {
return state;
return;
}
const cardIdx = zone.cards.findIndex(c => c.id === cardId);
if (cardIdx < 0) {
return state;
}
const updatedCards = [...zone.cards];
updatedCards[cardIdx] = {
...updatedCards[cardIdx],
attachPlayerId: targetPlayerId,
attachZone: targetZone,
attachCardId: targetCardId,
};
return updateZone(state, gameId, playerId, startZone, { cards: updatedCards });
}
case Types.TOKEN_CREATED: {
const { gameId, playerId, data } = action;
const {
zoneName,
cardId,
cardName,
color,
pt,
annotation,
destroyOnZoneChange,
x,
y,
cardProviderId,
faceDown,
} = data;
const game = state.games[gameId];
if (!game) {
return state;
}
const player = game.players[playerId];
if (!player) {
return state;
}
const zone = player.zones[zoneName];
if (!zone) {
return state;
}
const newCard: Data.ServerInfo_Card = create(Data.ServerInfo_CardSchema, {
id: cardId,
name: cardName,
x,
y,
faceDown,
tapped: false,
attacking: false,
color,
pt,
annotation,
destroyOnZoneChange,
doesntUntap: false,
counterList: [],
attachPlayerId: -1,
attachZone: '',
attachCardId: -1,
providerId: cardProviderId,
const newCard = create(Data.ServerInfo_CardSchema, {
id: cardId, name: cardName, x, y, faceDown,
tapped: false, attacking: false, color, pt, annotation, destroyOnZoneChange,
doesntUntap: false, counterList: [],
attachPlayerId: -1, attachZone: '', attachCardId: -1, providerId: cardProviderId,
});
return updateZone(state, gameId, playerId, zoneName, {
cards: [...zone.cards, newCard],
cardCount: zone.cardCount + 1,
});
}
zone.order.push(newCard.id);
zone.byId[newCard.id] = newCard;
zone.cardCount++;
},
case Types.CARD_ATTR_CHANGED: {
const { gameId, playerId, data } = action;
cardAttrChanged: (state, action: PayloadAction<{ gameId: number; playerId: number; data: Data.Event_SetCardAttr }>) => {
const { gameId, playerId, data } = action.payload;
const { zoneName, cardId, attribute, attrValue } = data;
const game = state.games[gameId];
if (!game) {
return state;
const card = state.games[gameId]?.players[playerId]?.zones[zoneName]?.byId[cardId];
if (!card) {
return;
}
const player = game.players[playerId];
if (!player) {
return state;
}
const zone = player.zones[zoneName];
if (!zone) {
return state;
}
const cardIdx = zone.cards.findIndex(c => c.id === cardId);
if (cardIdx < 0) {
return state;
}
const attrPatch: Partial<Data.ServerInfo_Card> = {};
switch (attribute as Data.CardAttribute) {
case Data.CardAttribute.AttrTapped: attrPatch.tapped = attrValue === '1'; break;
case Data.CardAttribute.AttrAttacking: attrPatch.attacking = attrValue === '1'; break;
case Data.CardAttribute.AttrFaceDown: attrPatch.faceDown = attrValue === '1'; break;
case Data.CardAttribute.AttrColor: attrPatch.color = attrValue; break;
case Data.CardAttribute.AttrPT: attrPatch.pt = attrValue; break;
case Data.CardAttribute.AttrAnnotation: attrPatch.annotation = attrValue; break;
case Data.CardAttribute.AttrDoesntUntap: attrPatch.doesntUntap = attrValue === '1'; break;
case Data.CardAttribute.AttrTapped: card.tapped = attrValue === '1'; break;
case Data.CardAttribute.AttrAttacking: card.attacking = attrValue === '1'; break;
case Data.CardAttribute.AttrFaceDown: card.faceDown = attrValue === '1'; break;
case Data.CardAttribute.AttrColor: card.color = attrValue; break;
case Data.CardAttribute.AttrPT: card.pt = attrValue; break;
case Data.CardAttribute.AttrAnnotation: card.annotation = attrValue; break;
case Data.CardAttribute.AttrDoesntUntap: card.doesntUntap = attrValue === '1'; break;
}
},
const updatedCards = [...zone.cards];
updatedCards[cardIdx] = { ...updatedCards[cardIdx], ...attrPatch };
return updateZone(state, gameId, playerId, zoneName, { cards: updatedCards });
}
case Types.CARD_COUNTER_CHANGED: {
const { gameId, playerId, data } = action;
cardCounterChanged: (state, action: PayloadAction<{ gameId: number; playerId: number; data: Data.Event_SetCardCounter }>) => {
const { gameId, playerId, data } = action.payload;
const { zoneName, cardId, counterId, counterValue } = data;
const game = state.games[gameId];
if (!game) {
return state;
const card = state.games[gameId]?.players[playerId]?.zones[zoneName]?.byId[cardId];
if (!card) {
return;
}
const player = game.players[playerId];
if (!player) {
return state;
}
const zone = player.zones[zoneName];
if (!zone) {
return state;
}
const cardIdx = zone.cards.findIndex(c => c.id === cardId);
if (cardIdx < 0) {
return state;
}
const card = zone.cards[cardIdx];
let newCounterList: Data.ServerInfo_CardCounter[];
if (counterValue <= 0) {
newCounterList = card.counterList.filter(c => c.id !== counterId);
card.counterList = card.counterList.filter(c => c.id !== counterId);
} else {
const existing = card.counterList.findIndex(c => c.id === counterId);
newCounterList =
existing >= 0
? card.counterList.map(c => (c.id === counterId ? { ...c, value: counterValue } : c))
: [...card.counterList, create(Data.ServerInfo_CardCounterSchema, { id: counterId, value: counterValue })];
const idx = card.counterList.findIndex(c => c.id === counterId);
if (idx >= 0) {
card.counterList[idx] = { ...card.counterList[idx], value: counterValue };
} else {
card.counterList.push(create(Data.ServerInfo_CardCounterSchema, { id: counterId, value: counterValue }));
}
}
const updatedCards = [...zone.cards];
updatedCards[cardIdx] = { ...card, counterList: newCounterList };
return updateZone(state, gameId, playerId, zoneName, { cards: updatedCards });
}
},
// ── Arrows ───────────────────────────────────────────────────────────────
case Types.ARROW_CREATED: {
const { gameId, playerId, data } = action;
const { arrowInfo } = data;
const game = state.games[gameId];
if (!game) {
return state;
arrowCreated: (state, action: PayloadAction<{ gameId: number; playerId: number; data: Data.Event_CreateArrow }>) => {
const { gameId, playerId, data } = action.payload;
const player = state.games[gameId]?.players[playerId];
if (player) {
player.arrows[data.arrowInfo.id] = data.arrowInfo;
}
const player = game.players[playerId];
if (!player) {
return state;
}
return updatePlayer(state, gameId, playerId, {
arrows: { ...player.arrows, [arrowInfo.id]: arrowInfo },
});
}
},
case Types.ARROW_DELETED: {
const { gameId, playerId, data } = action;
const { arrowId } = data;
const game = state.games[gameId];
if (!game) {
return state;
arrowDeleted: (state, action: PayloadAction<{ gameId: number; playerId: number; data: Data.Event_DeleteArrow }>) => {
const { gameId, playerId, data } = action.payload;
const player = state.games[gameId]?.players[playerId];
if (player) {
delete player.arrows[data.arrowId];
}
const player = game.players[playerId];
if (!player) {
return state;
}
const arrows = { ...player.arrows };
delete arrows[arrowId];
return updatePlayer(state, gameId, playerId, { arrows });
}
},
// ── Player counters ───────────────────────────────────────────────────────
case Types.COUNTER_CREATED: {
const { gameId, playerId, data } = action;
const { counterInfo } = data;
const game = state.games[gameId];
if (!game) {
return state;
counterCreated: (state, action: PayloadAction<{ gameId: number; playerId: number; data: Data.Event_CreateCounter }>) => {
const { gameId, playerId, data } = action.payload;
const player = state.games[gameId]?.players[playerId];
if (player) {
player.counters[data.counterInfo.id] = data.counterInfo;
}
const player = game.players[playerId];
if (!player) {
return state;
}
return updatePlayer(state, gameId, playerId, {
counters: { ...player.counters, [counterInfo.id]: counterInfo },
});
}
},
case Types.COUNTER_SET: {
const { gameId, playerId, data } = action;
const { counterId, value } = data;
const game = state.games[gameId];
if (!game) {
return state;
counterSet: (state, action: PayloadAction<{ gameId: number; playerId: number; data: Data.Event_SetCounter }>) => {
const { gameId, playerId, data } = action.payload;
const counter = state.games[gameId]?.players[playerId]?.counters[data.counterId];
if (counter) {
counter.count = data.value;
}
const player = game.players[playerId];
if (!player) {
return state;
}
const counter = player.counters[counterId];
if (!counter) {
return state;
}
return updatePlayer(state, gameId, playerId, {
counters: { ...player.counters, [counterId]: { ...counter, count: value } },
});
}
},
case Types.COUNTER_DELETED: {
const { gameId, playerId, data } = action;
const { counterId } = data;
const game = state.games[gameId];
if (!game) {
return state;
counterDeleted: (state, action: PayloadAction<{ gameId: number; playerId: number; data: Data.Event_DelCounter }>) => {
const { gameId, playerId, data } = action.payload;
const player = state.games[gameId]?.players[playerId];
if (player) {
delete player.counters[data.counterId];
}
const player = game.players[playerId];
if (!player) {
return state;
}
const counters = { ...player.counters };
delete counters[counterId];
return updatePlayer(state, gameId, playerId, { counters });
}
},
// ── Zone operations ───────────────────────────────────────────────────────
case Types.CARDS_DRAWN: {
const { gameId, playerId, data } = action;
cardsDrawn: (state, action: PayloadAction<{ gameId: number; playerId: number; data: Data.Event_DrawCards }>) => {
const { gameId, playerId, data } = action.payload;
const { number: drawCount, cards } = data;
const game = state.games[gameId];
if (!game) {
return state;
}
const player = game.players[playerId];
const player = state.games[gameId]?.players[playerId];
if (!player) {
return state;
return;
}
const deckZone = player.zones['deck'];
const handZone = player.zones['hand'];
if (!handZone) {
return state;
return;
}
// Decrement deck count for the drawing player
let newState = deckZone
? updateZone(state, gameId, playerId, 'deck', {
cardCount: Math.max(0, deckZone.cardCount - drawCount),
})
: state;
if (deckZone) {
deckZone.cardCount = Math.max(0, deckZone.cardCount - drawCount);
}
// Append revealed cards to hand (cards array is empty for non-drawing players;
// use drawCount for count math so all observers track the correct hand/deck size)
const updatedHand = newState.games[gameId]!.players[playerId]!.zones['hand']!;
return updateZone(newState, gameId, playerId, 'hand', {
cards: [...updatedHand.cards, ...cards],
cardCount: updatedHand.cardCount + drawCount,
});
}
for (const card of cards) {
handZone.order.push(card.id);
handZone.byId[card.id] = card;
}
handZone.cardCount += drawCount;
},
case Types.CARDS_REVEALED: {
const { gameId, playerId, data } = action;
cardsRevealed: (state, action: PayloadAction<{ gameId: number; playerId: number; data: Data.Event_RevealCards }>) => {
const { gameId, playerId, data } = action.payload;
const { zoneName, cards } = data;
const game = state.games[gameId];
if (!game) {
return state;
}
const player = game.players[playerId];
if (!player) {
return state;
}
const zone = player.zones[zoneName];
const zone = state.games[gameId]?.players[playerId]?.zones[zoneName];
if (!zone) {
return state;
return;
}
// Merge revealed card data into existing zone cards (update existing, append new)
const merged = [...zone.cards];
for (const revealedCard of cards) {
const idx = merged.findIndex(c => c.id === revealedCard.id);
if (idx >= 0) {
merged[idx] = { ...merged[idx], ...revealedCard };
if (zone.byId[revealedCard.id]) {
Object.assign(zone.byId[revealedCard.id], revealedCard);
} else {
merged.push(revealedCard);
zone.order.push(revealedCard.id);
zone.byId[revealedCard.id] = revealedCard;
}
}
return updateZone(state, gameId, playerId, zoneName, { cards: merged });
}
},
case Types.ZONE_PROPERTIES_CHANGED: {
const { gameId, playerId, data } = action;
const { zoneName, alwaysRevealTopCard, alwaysLookAtTopCard } = data;
const patch: Partial<ZoneEntry> = {};
if (alwaysRevealTopCard !== undefined && alwaysRevealTopCard !== null) {
patch.alwaysRevealTopCard = alwaysRevealTopCard;
zonePropertiesChanged: (state, action: PayloadAction<{ gameId: number; playerId: number; data: Data.Event_ChangeZoneProperties }>) => {
const { gameId, playerId, data } = action.payload;
const zone = state.games[gameId]?.players[playerId]?.zones[data.zoneName];
if (!zone) {
return;
}
if (alwaysLookAtTopCard !== undefined && alwaysLookAtTopCard !== null) {
patch.alwaysLookAtTopCard = alwaysLookAtTopCard;
if (data.alwaysRevealTopCard !== undefined && data.alwaysRevealTopCard !== null) {
zone.alwaysRevealTopCard = data.alwaysRevealTopCard;
}
return updateZone(state, gameId, playerId, zoneName, patch);
}
if (data.alwaysLookAtTopCard !== undefined && data.alwaysLookAtTopCard !== null) {
zone.alwaysLookAtTopCard = data.alwaysLookAtTopCard;
}
},
// ── Turn / phase ──────────────────────────────────────────────────────────
case Types.ACTIVE_PLAYER_SET: {
return updateGame(state, action.gameId, { activePlayerId: action.activePlayerId });
}
activePlayerSet: (state, action: PayloadAction<{ gameId: number; activePlayerId: number }>) => {
const game = state.games[action.payload.gameId];
if (game) {
game.activePlayerId = action.payload.activePlayerId;
}
},
case Types.ACTIVE_PHASE_SET: {
return updateGame(state, action.gameId, { activePhase: action.phase });
}
activePhaseSet: (state, action: PayloadAction<{ gameId: number; phase: number }>) => {
const game = state.games[action.payload.gameId];
if (game) {
game.activePhase = action.payload.phase;
}
},
case Types.TURN_REVERSED: {
return updateGame(state, action.gameId, { reversed: action.reversed });
}
turnReversed: (state, action: PayloadAction<{ gameId: number; reversed: boolean }>) => {
const game = state.games[action.payload.gameId];
if (game) {
game.reversed = action.payload.reversed;
}
},
// ── Chat ──────────────────────────────────────────────────────────────────
case Types.GAME_SAY: {
const { gameId, playerId, message } = action;
gameSay: (state, action: PayloadAction<{ gameId: number; playerId: number; message: string }>) => {
const { gameId, playerId, message } = action.payload;
const game = state.games[gameId];
if (!game) {
return state;
return;
}
const newMessage: GameMessage = { playerId, message, timeReceived: Date.now() };
return updateGame(state, gameId, {
messages: [...game.messages, newMessage],
});
}
if (game.messages.length >= MAX_GAME_MESSAGES) {
game.messages = game.messages.slice(game.messages.length - MAX_GAME_MESSAGES + 1);
}
game.messages.push({ playerId, message, timeReceived: Date.now() });
},
// ── Log-only events (state unchanged, future game log will use these) ─────
case Types.ZONE_SHUFFLED:
case Types.ZONE_DUMPED:
case Types.DIE_ROLLED: {
return state;
}
// ── Log-only events ─────────────────────────────────────────────────────
zoneShuffled: (_state, _action: PayloadAction<{ gameId: number; playerId: number; data: Data.Event_Shuffle }>) => {},
zoneDumped: (_state, _action: PayloadAction<{ gameId: number; playerId: number; data: Data.Event_DumpZone }>) => {},
dieRolled: (_state, _action: PayloadAction<{ gameId: number; playerId: number; data: Data.Event_RollDie }>) => {},
},
});
default:
return state;
}
};
export const gamesReducer = gamesSlice.reducer;

View file

@ -146,8 +146,8 @@ describe('Selectors', () => {
it('getActiveGameIds → returns numeric array of gameIds', () => {
const state = makeState({
games: {
1: makeGameEntry({ gameId: 1 }),
2: makeGameEntry({ gameId: 2 }),
1: makeGameEntry(),
2: makeGameEntry(),
},
});
const ids = Selectors.getActiveGameIds(rootState(state));

View file

@ -1,6 +1,6 @@
import { createSelector } from '@reduxjs/toolkit';
import type { Data } from '@app/types';
import { GamesState, GameEntry, PlayerEntry, ZoneEntry } from './game.interfaces';
import type { Data, Enriched } from '@app/types';
import { GamesState } from './game.interfaces';
interface State {
games: GamesState;
@ -9,21 +9,39 @@ interface State {
const EMPTY_ARRAY: Data.ServerInfo_Card[] = [];
const EMPTY_OBJECT = {} as Record<string, never>;
/**
* Memoized cache for materialized zone card arrays. Keyed by the zone object
* identity so that repeated selector calls on the same zone reuse the same
* array reference this preserves React referential equality and avoids
* spurious re-renders when `getCards` is called from a selector.
*/
const zoneCardsCache = new WeakMap<Enriched.ZoneEntry, Data.ServerInfo_Card[]>();
function materializeZoneCards(zone: Enriched.ZoneEntry): Data.ServerInfo_Card[] {
const cached = zoneCardsCache.get(zone);
if (cached) {
return cached;
}
const arr = zone.order.map(id => zone.byId[id]);
zoneCardsCache.set(zone, arr);
return arr;
}
export const Selectors = {
getGames: ({ games }: State): { [gameId: number]: GameEntry } => games.games,
getGames: ({ games }: State): { [gameId: number]: Enriched.GameEntry } => games.games,
getGame: ({ games }: State, gameId: number): GameEntry | undefined => games.games[gameId],
getGame: ({ games }: State, gameId: number): Enriched.GameEntry | undefined => games.games[gameId],
getPlayers: ({ games }: State, gameId: number): { [playerId: number]: PlayerEntry } | undefined =>
getPlayers: ({ games }: State, gameId: number): { [playerId: number]: Enriched.PlayerEntry } | undefined =>
games.games[gameId]?.players,
getPlayer: ({ games }: State, gameId: number, playerId: number): PlayerEntry | undefined =>
getPlayer: ({ games }: State, gameId: number, playerId: number): Enriched.PlayerEntry | undefined =>
games.games[gameId]?.players[playerId],
getLocalPlayerId: ({ games }: State, gameId: number): number | undefined =>
games.games[gameId]?.localPlayerId,
getLocalPlayer: (state: State, gameId: number): PlayerEntry | undefined => {
getLocalPlayer: (state: State, gameId: number): Enriched.PlayerEntry | undefined => {
const game = state.games.games[gameId];
if (!game) {
return undefined;
@ -35,7 +53,7 @@ export const Selectors = {
{ games }: State,
gameId: number,
playerId: number
): { [zoneName: string]: ZoneEntry } | undefined =>
): { [zoneName: string]: Enriched.ZoneEntry } | undefined =>
games.games[gameId]?.players[playerId]?.zones,
getZone: (
@ -43,10 +61,12 @@ export const Selectors = {
gameId: number,
playerId: number,
zoneName: string
): ZoneEntry | undefined => games.games[gameId]?.players[playerId]?.zones[zoneName],
): Enriched.ZoneEntry | undefined => games.games[gameId]?.players[playerId]?.zones[zoneName],
getCards: ({ games }: State, gameId: number, playerId: number, zoneName: string) =>
games.games[gameId]?.players[playerId]?.zones[zoneName]?.cards ?? EMPTY_ARRAY,
getCards: ({ games }: State, gameId: number, playerId: number, zoneName: string): Data.ServerInfo_Card[] => {
const zone = games.games[gameId]?.players[playerId]?.zones[zoneName];
return zone ? materializeZoneCards(zone) : EMPTY_ARRAY;
},
getCounters: ({ games }: State, gameId: number, playerId: number) =>
games.games[gameId]?.players[playerId]?.counters ?? EMPTY_OBJECT,

View file

@ -1,34 +1,40 @@
import { gamesSlice } from './game.reducer';
const a = gamesSlice.actions;
export const Types = {
CLEAR_STORE: '[Games] Clear Store',
GAME_JOINED: '[Games] Game Joined',
GAME_LEFT: '[Games] Game Left',
GAME_CLOSED: '[Games] Game Closed',
GAME_HOST_CHANGED: '[Games] Game Host Changed',
GAME_STATE_CHANGED: '[Games] Game State Changed',
PLAYER_JOINED: '[Games] Player Joined',
PLAYER_LEFT: '[Games] Player Left',
PLAYER_PROPERTIES_CHANGED: '[Games] Player Properties Changed',
KICKED: '[Games] Kicked',
CARD_MOVED: '[Games] Card Moved',
CARD_FLIPPED: '[Games] Card Flipped',
CARD_DESTROYED: '[Games] Card Destroyed',
CARD_ATTACHED: '[Games] Card Attached',
TOKEN_CREATED: '[Games] Token Created',
CARD_ATTR_CHANGED: '[Games] Card Attribute Changed',
CARD_COUNTER_CHANGED: '[Games] Card Counter Changed',
ARROW_CREATED: '[Games] Arrow Created',
ARROW_DELETED: '[Games] Arrow Deleted',
COUNTER_CREATED: '[Games] Counter Created',
COUNTER_SET: '[Games] Counter Set',
COUNTER_DELETED: '[Games] Counter Deleted',
CARDS_DRAWN: '[Games] Cards Drawn',
CARDS_REVEALED: '[Games] Cards Revealed',
ZONE_SHUFFLED: '[Games] Zone Shuffled',
DIE_ROLLED: '[Games] Die Rolled',
ACTIVE_PLAYER_SET: '[Games] Active Player Set',
ACTIVE_PHASE_SET: '[Games] Active Phase Set',
TURN_REVERSED: '[Games] Turn Reversed',
ZONE_DUMPED: '[Games] Zone Dumped',
ZONE_PROPERTIES_CHANGED: '[Games] Zone Properties Changed',
GAME_SAY: '[Games] Game Say',
CLEAR_STORE: a.clearStore.type,
GAME_JOINED: a.gameJoined.type,
GAME_LEFT: a.gameLeft.type,
GAME_CLOSED: a.gameClosed.type,
GAME_HOST_CHANGED: a.gameHostChanged.type,
GAME_STATE_CHANGED: a.gameStateChanged.type,
PLAYER_JOINED: a.playerJoined.type,
PLAYER_LEFT: a.playerLeft.type,
PLAYER_PROPERTIES_CHANGED: a.playerPropertiesChanged.type,
KICKED: a.kicked.type,
CARD_MOVED: a.cardMoved.type,
CARD_FLIPPED: a.cardFlipped.type,
CARD_DESTROYED: a.cardDestroyed.type,
CARD_ATTACHED: a.cardAttached.type,
TOKEN_CREATED: a.tokenCreated.type,
CARD_ATTR_CHANGED: a.cardAttrChanged.type,
CARD_COUNTER_CHANGED: a.cardCounterChanged.type,
ARROW_CREATED: a.arrowCreated.type,
ARROW_DELETED: a.arrowDeleted.type,
COUNTER_CREATED: a.counterCreated.type,
COUNTER_SET: a.counterSet.type,
COUNTER_DELETED: a.counterDeleted.type,
CARDS_DRAWN: a.cardsDrawn.type,
CARDS_REVEALED: a.cardsRevealed.type,
ZONE_SHUFFLED: a.zoneShuffled.type,
DIE_ROLLED: a.dieRolled.type,
ACTIVE_PLAYER_SET: a.activePlayerSet.type,
ACTIVE_PHASE_SET: a.activePhaseSet.type,
TURN_REVERSED: a.turnReversed.type,
ZONE_DUMPED: a.zoneDumped.type,
ZONE_PROPERTIES_CHANGED: a.zonePropertiesChanged.type,
GAME_SAY: a.gameSay.type,
} as const;
export { MAX_GAME_MESSAGES } from './game.reducer';

View file

@ -25,5 +25,3 @@ export {
Dispatch as RoomsDispatch } from './rooms';
export * from './rooms/rooms.interfaces';

View file

@ -16,10 +16,49 @@ export function makeUser(
});
}
export function makeRoom(overrides: Partial<Omit<Enriched.Room, '$typeName' | '$unknown'>> = {}): Enriched.Room {
const { gametypeMap = {}, order = 0, gameList = [], ...protoOverrides } = overrides;
type MakeGameOverrides = MessageInitShape<typeof Data.ServerInfo_GameSchema> & {
gameType?: string;
};
/**
* Test fixture for Enriched.Game.
*
* Accepts proto field shorthands (gameId, description, etc.) which populate
* `info`, plus the top-level client field `gameType`.
*/
export function makeGame(overrides: MakeGameOverrides = {}): Enriched.Game {
const { gameType = '', ...protoFields } = overrides;
return {
...create(Data.ServerInfo_RoomSchema, {
info: create(Data.ServerInfo_GameSchema, {
gameId: 1,
roomId: 1,
description: 'Test Game',
gameTypes: [],
started: false,
...protoFields,
}),
gameType,
};
}
type MakeRoomOverrides = MessageInitShape<typeof Data.ServerInfo_RoomSchema> & {
gametypeMap?: Enriched.GametypeMap;
order?: number;
games?: { [gameId: number]: Enriched.Game };
users?: { [userName: string]: Data.ServerInfo_User };
};
/**
* Test fixture for Enriched.Room.
*
* Accepts proto field shorthands (roomId, name, etc.) which populate `info`,
* plus normalized collections (games, users, gametypeMap) and the client-only
* `order` field.
*/
export function makeRoom(overrides: MakeRoomOverrides = {}): Enriched.Room {
const { gametypeMap = {}, order = 0, games = {}, users = {}, ...protoFields } = overrides;
return {
info: create(Data.ServerInfo_RoomSchema, {
roomId: 1,
name: 'Test Room',
description: '',
@ -29,29 +68,12 @@ export function makeRoom(overrides: Partial<Omit<Enriched.Room, '$typeName' | '$
autoJoin: false,
playerCount: 0,
userList: [],
...protoOverrides,
...protoFields,
}),
gameList,
gametypeMap,
order,
};
}
export function makeGame(
overrides: Partial<Omit<Enriched.Game & { startTime: number }, '$typeName' | '$unknown'>> = {},
): Enriched.Game & { startTime: number } {
const { gameType = '', startTime = 0, ...protoOverrides } = overrides;
return {
...create(Data.ServerInfo_GameSchema, {
gameId: 1,
roomId: 1,
description: 'Test Game',
gameTypes: [],
started: false,
...protoOverrides,
}),
gameType,
startTime,
games,
users,
};
}
@ -72,7 +94,6 @@ export function makeRoomsState(overrides: Partial<RoomsState> = {}): RoomsState
rooms: {
1: makeRoom({ roomId: 1 }),
},
games: {},
joinedRoomIds: {},
joinedGameIds: {},
messages: {},

View file

@ -5,65 +5,68 @@ import { App } from '@app/types';
describe('Actions', () => {
it('clearStore', () => {
expect(Actions.clearStore()).toEqual({ type: Types.CLEAR_STORE });
expect(Actions.clearStore()).toEqual({ type: Types.CLEAR_STORE, payload: undefined });
});
it('updateRooms', () => {
const rooms = [makeRoom()];
expect(Actions.updateRooms(rooms)).toEqual({ type: Types.UPDATE_ROOMS, rooms });
expect(Actions.updateRooms({ rooms })).toEqual({ type: Types.UPDATE_ROOMS, payload: { rooms } });
});
it('joinRoom', () => {
const roomInfo = makeRoom({ roomId: 2 });
expect(Actions.joinRoom(roomInfo)).toEqual({ type: Types.JOIN_ROOM, roomInfo });
expect(Actions.joinRoom({ roomInfo })).toEqual({ type: Types.JOIN_ROOM, payload: { roomInfo } });
});
it('leaveRoom', () => {
expect(Actions.leaveRoom(3)).toEqual({ type: Types.LEAVE_ROOM, roomId: 3 });
expect(Actions.leaveRoom({ roomId: 3 })).toEqual({ type: Types.LEAVE_ROOM, payload: { roomId: 3 } });
});
it('addMessage', () => {
const message = makeMessage();
expect(Actions.addMessage(1, message)).toEqual({ type: Types.ADD_MESSAGE, roomId: 1, message });
expect(Actions.addMessage({ roomId: 1, message })).toEqual({ type: Types.ADD_MESSAGE, payload: { roomId: 1, message } });
});
it('updateGames', () => {
const games = [makeGame()];
expect(Actions.updateGames(1, games)).toEqual({ type: Types.UPDATE_GAMES, roomId: 1, games });
expect(Actions.updateGames({ roomId: 1, games })).toEqual({ type: Types.UPDATE_GAMES, payload: { roomId: 1, games } });
});
it('userJoined', () => {
const user = makeUser();
expect(Actions.userJoined(1, user)).toEqual({ type: Types.USER_JOINED, roomId: 1, user });
expect(Actions.userJoined({ roomId: 1, user })).toEqual({ type: Types.USER_JOINED, payload: { roomId: 1, user } });
});
it('userLeft', () => {
expect(Actions.userLeft(1, 'Alice')).toEqual({ type: Types.USER_LEFT, roomId: 1, name: 'Alice' });
expect(Actions.userLeft({ roomId: 1, name: 'Alice' })).toEqual({ type: Types.USER_LEFT, payload: { roomId: 1, name: 'Alice' } });
});
it('sortGames', () => {
expect(Actions.sortGames(1, App.GameSortField.START_TIME, App.SortDirection.ASC)).toEqual({
expect(Actions.sortGames({ field: App.GameSortField.START_TIME, order: App.SortDirection.ASC })).toEqual({
type: Types.SORT_GAMES,
roomId: 1,
field: App.GameSortField.START_TIME,
order: App.SortDirection.ASC,
payload: {
field: App.GameSortField.START_TIME,
order: App.SortDirection.ASC,
},
});
});
it('removeMessages', () => {
expect(Actions.removeMessages(1, 'Alice', 3)).toEqual({
expect(Actions.removeMessages({ roomId: 1, name: 'Alice', amount: 3 })).toEqual({
type: Types.REMOVE_MESSAGES,
roomId: 1,
name: 'Alice',
amount: 3,
payload: {
roomId: 1,
name: 'Alice',
amount: 3,
},
});
});
it('gameCreated', () => {
expect(Actions.gameCreated(2)).toEqual({ type: Types.GAME_CREATED, roomId: 2 });
expect(Actions.gameCreated({ roomId: 2 })).toEqual({ type: Types.GAME_CREATED, payload: { roomId: 2 } });
});
it('joinedGame', () => {
expect(Actions.joinedGame(1, 5)).toEqual({ type: Types.JOINED_GAME, roomId: 1, gameId: 5 });
expect(Actions.joinedGame({ roomId: 1, gameId: 5 })).toEqual({ type: Types.JOINED_GAME, payload: { roomId: 1, gameId: 5 } });
});
});

View file

@ -1,75 +1,5 @@
import { App, Data, Enriched } from '@app/types';
import { roomsSlice } from './rooms.reducer';
import { Types } from './rooms.types';
export const Actions = {
clearStore: () => ({
type: Types.CLEAR_STORE,
}),
updateRooms: (rooms: Data.ServerInfo_Room[]) => ({
type: Types.UPDATE_ROOMS,
rooms,
}),
joinRoom: (roomInfo: Data.ServerInfo_Room) => ({
type: Types.JOIN_ROOM,
roomInfo,
}),
leaveRoom: (roomId: number) => ({
type: Types.LEAVE_ROOM,
roomId,
}),
addMessage: (roomId: number, message: Enriched.Message) => ({
type: Types.ADD_MESSAGE,
roomId,
message,
}),
updateGames: (roomId: number, games: Data.ServerInfo_Game[]) => ({
type: Types.UPDATE_GAMES,
roomId,
games,
}),
userJoined: (roomId: number, user: Data.ServerInfo_User) => ({
type: Types.USER_JOINED,
roomId,
user,
}),
userLeft: (roomId: number, name: string) => ({
type: Types.USER_LEFT,
roomId,
name,
}),
sortGames: (roomId: number, field: App.GameSortField, order: App.SortDirection) => ({
type: Types.SORT_GAMES,
roomId,
field,
order,
}),
removeMessages: (roomId: number, name: string, amount: number) => ({
type: Types.REMOVE_MESSAGES,
roomId,
name,
amount,
}),
gameCreated: (roomId: number) => ({
type: Types.GAME_CREATED,
roomId,
}),
joinedGame: (roomId: number, gameId: number) => ({
type: Types.JOINED_GAME,
roomId,
gameId,
}),
}
export const Actions = roomsSlice.actions;
export type RoomsAction = ReturnType<typeof Actions[keyof typeof Actions]>;

View file

@ -26,70 +26,70 @@ describe('Dispatch', () => {
it('updateRooms dispatches Actions.updateRooms()', () => {
const rooms = [makeRoom()];
Dispatch.updateRooms(rooms);
expect(mockDispatch).toHaveBeenCalledWith(Actions.updateRooms(rooms));
expect(mockDispatch).toHaveBeenCalledWith(Actions.updateRooms({ rooms }));
});
it('joinRoom dispatches Actions.joinRoom()', () => {
const roomInfo = makeRoom({ roomId: 2 });
Dispatch.joinRoom(roomInfo);
expect(mockDispatch).toHaveBeenCalledWith(Actions.joinRoom(roomInfo));
expect(mockDispatch).toHaveBeenCalledWith(Actions.joinRoom({ roomInfo }));
});
it('leaveRoom dispatches Actions.leaveRoom()', () => {
Dispatch.leaveRoom(3);
expect(mockDispatch).toHaveBeenCalledWith(Actions.leaveRoom(3));
expect(mockDispatch).toHaveBeenCalledWith(Actions.leaveRoom({ roomId: 3 }));
});
it('addMessage with message.name falsy → dispatches only Actions.addMessage()', () => {
const message = { ...makeMessage(), name: undefined };
Dispatch.addMessage(1, message);
expect(mockDispatch).toHaveBeenCalledTimes(1);
expect(mockDispatch).toHaveBeenCalledWith(Actions.addMessage(1, message));
expect(mockDispatch).toHaveBeenCalledWith(Actions.addMessage({ roomId: 1, message }));
});
it('addMessage with message.name truthy → dispatches Actions.addMessage()', () => {
const message = { ...makeMessage(), name: 'Alice' };
Dispatch.addMessage(1, message);
expect(mockDispatch).toHaveBeenCalledTimes(1);
expect(mockDispatch).toHaveBeenCalledWith(Actions.addMessage(1, message));
expect(mockDispatch).toHaveBeenCalledWith(Actions.addMessage({ roomId: 1, message }));
});
it('updateGames dispatches Actions.updateGames()', () => {
const games = [makeGame()];
Dispatch.updateGames(1, games);
expect(mockDispatch).toHaveBeenCalledWith(Actions.updateGames(1, games));
expect(mockDispatch).toHaveBeenCalledWith(Actions.updateGames({ roomId: 1, games }));
});
it('userJoined dispatches Actions.userJoined()', () => {
const user = makeUser();
Dispatch.userJoined(1, user);
expect(mockDispatch).toHaveBeenCalledWith(Actions.userJoined(1, user));
expect(mockDispatch).toHaveBeenCalledWith(Actions.userJoined({ roomId: 1, user }));
});
it('userLeft dispatches Actions.userLeft()', () => {
Dispatch.userLeft(1, 'Alice');
expect(mockDispatch).toHaveBeenCalledWith(Actions.userLeft(1, 'Alice'));
expect(mockDispatch).toHaveBeenCalledWith(Actions.userLeft({ roomId: 1, name: 'Alice' }));
});
it('sortGames dispatches Actions.sortGames()', () => {
Dispatch.sortGames(1, App.GameSortField.START_TIME, App.SortDirection.ASC);
expect(mockDispatch).toHaveBeenCalledWith(
Actions.sortGames(1, App.GameSortField.START_TIME, App.SortDirection.ASC)
Actions.sortGames({ field: App.GameSortField.START_TIME, order: App.SortDirection.ASC })
);
});
it('removeMessages dispatches Actions.removeMessages()', () => {
Dispatch.removeMessages(1, 'Alice', 5);
expect(mockDispatch).toHaveBeenCalledWith(Actions.removeMessages(1, 'Alice', 5));
expect(mockDispatch).toHaveBeenCalledWith(Actions.removeMessages({ roomId: 1, name: 'Alice', amount: 5 }));
});
it('gameCreated dispatches Actions.gameCreated()', () => {
Dispatch.gameCreated(2);
expect(mockDispatch).toHaveBeenCalledWith(Actions.gameCreated(2));
expect(mockDispatch).toHaveBeenCalledWith(Actions.gameCreated({ roomId: 2 }));
});
it('joinedGame dispatches Actions.joinedGame()', () => {
Dispatch.joinedGame(1, 5);
expect(mockDispatch).toHaveBeenCalledWith(Actions.joinedGame(1, 5));
expect(mockDispatch).toHaveBeenCalledWith(Actions.joinedGame({ roomId: 1, gameId: 5 }));
});
});

View file

@ -9,47 +9,46 @@ export const Dispatch = {
},
updateRooms: (rooms: Data.ServerInfo_Room[]) => {
store.dispatch(Actions.updateRooms(rooms));
store.dispatch(Actions.updateRooms({ rooms }));
},
joinRoom: (roomInfo: Data.ServerInfo_Room) => {
store.dispatch(Actions.joinRoom(roomInfo));
store.dispatch(Actions.joinRoom({ roomInfo }));
},
leaveRoom: (roomId: number) => {
store.dispatch(Actions.leaveRoom(roomId));
store.dispatch(Actions.leaveRoom({ roomId }));
},
addMessage: (roomId: number, message: Enriched.Message) => {
store.dispatch(Actions.addMessage(roomId, message));
store.dispatch(Actions.addMessage({ roomId, message }));
},
updateGames: (roomId: number, games: Data.ServerInfo_Game[]) => {
store.dispatch(Actions.updateGames(roomId, games));
store.dispatch(Actions.updateGames({ roomId, games }));
},
userJoined: (roomId: number, user: Data.ServerInfo_User) => {
store.dispatch(Actions.userJoined(roomId, user));
store.dispatch(Actions.userJoined({ roomId, user }));
},
userLeft: (roomId: number, name: string) => {
store.dispatch(Actions.userLeft(roomId, name));
store.dispatch(Actions.userLeft({ roomId, name }));
},
sortGames: (roomId: number, field: App.GameSortField, order: App.SortDirection) => {
store.dispatch(Actions.sortGames(roomId, field, order));
store.dispatch(Actions.sortGames({ field, order }));
},
removeMessages: (roomId: number, name: string, amount: number) => {
store.dispatch(Actions.removeMessages(roomId, name, amount));
store.dispatch(Actions.removeMessages({ roomId, name, amount }));
},
gameCreated: (roomId: number) => {
store.dispatch(Actions.gameCreated(roomId));
store.dispatch(Actions.gameCreated({ roomId }));
},
joinedGame: (roomId: number, gameId: number) => {
store.dispatch(Actions.joinedGame(roomId, gameId));
store.dispatch(Actions.joinedGame({ roomId, gameId }));
}
}

View file

@ -2,7 +2,6 @@ import { App, Enriched } from '@app/types';
export interface RoomsState {
rooms: RoomsStateRooms;
games: RoomsStateGames;
joinedRoomIds: JoinedRooms;
joinedGameIds: JoinedGames;
messages: RoomsStateMessages;
@ -14,12 +13,6 @@ export interface RoomsStateRooms {
[roomId: number]: Enriched.Room;
}
export interface RoomsStateGames {
[roomId: number]: {
[gameId: number]: Enriched.Game;
};
}
export interface JoinedRooms {
[roomId: number]: boolean;
}

View file

@ -1,6 +1,7 @@
import { App } from '@app/types';
import { roomsReducer } from './rooms.reducer';
import { Types, MAX_ROOM_MESSAGES } from './rooms.types';
import { Actions } from './rooms.actions';
import { MAX_ROOM_MESSAGES } from './rooms.types';
import { makeGame, makeMessage, makeRoom, makeRoomsState, makeUser } from './__mocks__/rooms-fixtures';
// ── Initialisation ───────────────────────────────────────────────────────────
@ -14,7 +15,7 @@ describe('Initialisation', () => {
it('CLEAR_STORE → resets to initialState', () => {
const state = makeRoomsState({ joinedRoomIds: { 1: true } });
const result = roomsReducer(state, { type: Types.CLEAR_STORE });
const result = roomsReducer(state, Actions.clearStore());
expect(result.joinedRoomIds).toEqual({});
expect(result.rooms).toEqual({});
});
@ -22,63 +23,77 @@ describe('Initialisation', () => {
it('default → returns state unchanged for unknown action', () => {
const state = makeRoomsState();
const result = roomsReducer(state, { type: '@@UNKNOWN' });
expect(result).toBe(state);
expect(result).toEqual(state);
});
});
// ── UPDATE_ROOMS ──────────────────────────────────────────────────────────────
describe('UPDATE_ROOMS', () => {
it('merges rooms and strips gameList, gametypeList, userList from update', () => {
it('creates RoomEntry with empty normalized games/users for new room', () => {
const state = makeRoomsState({ rooms: {} });
const room = { ...makeRoom({ roomId: 1 }), gameList: [makeGame()], userList: [makeUser()], gametypeList: ['standard'] };
const result = roomsReducer(state, { type: Types.UPDATE_ROOMS, rooms: [room] });
// UPDATE_ROOMS carries raw ServerInfo_Room protos via the action
const room = makeRoom({ roomId: 1 }).info;
const result = roomsReducer(state, Actions.updateRooms({ rooms: [room] }));
expect(result.rooms[1]).toBeDefined();
expect(result.rooms[1].gameList).toBeUndefined();
expect(result.rooms[1].userList).toBeUndefined();
expect(result.rooms[1].gametypeList).toBeUndefined();
expect(result.rooms[1].info).toBe(room);
expect(result.rooms[1].games).toEqual({});
expect(result.rooms[1].users).toEqual({});
});
it('sets numeric order from array index', () => {
const state = makeRoomsState({ rooms: {} });
const rooms = [makeRoom({ roomId: 1 }), makeRoom({ roomId: 2 })];
const result = roomsReducer(state, { type: Types.UPDATE_ROOMS, rooms });
const rooms = [makeRoom({ roomId: 1 }).info, makeRoom({ roomId: 2 }).info];
const result = roomsReducer(state, Actions.updateRooms({ rooms }));
expect(result.rooms[1].order).toBe(0);
expect(result.rooms[2].order).toBe(1);
});
it('merges into existing room entry (preserves existing fields)', () => {
const existingRoom = makeRoom({ roomId: 1, name: 'Old Name', gameList: [makeGame()] });
it('preserves existing normalized games/users when merging into existing room', () => {
const existingGame = makeGame({ gameId: 42 });
const existingUser = makeUser({ name: 'alice' });
const existingRoom = makeRoom({
roomId: 1,
name: 'Old Name',
games: { 42: existingGame },
users: { alice: existingUser },
});
const state = makeRoomsState({ rooms: { 1: existingRoom } });
const update = makeRoom({ roomId: 1, name: 'New Name' });
const result = roomsReducer(state, { type: Types.UPDATE_ROOMS, rooms: [update] });
expect(result.rooms[1].name).toBe('New Name');
expect(result.rooms[1].gameList).toEqual([makeGame()]);
const update = makeRoom({ roomId: 1, name: 'New Name' }).info;
const result = roomsReducer(state, Actions.updateRooms({ rooms: [update] }));
expect(result.rooms[1].info.name).toBe('New Name');
expect(result.rooms[1].games[42]).toBe(existingGame);
expect(result.rooms[1].users['alice']).toBe(existingUser);
});
it('creates new room entry for unknown roomId', () => {
const state = makeRoomsState({ rooms: {} });
const room = makeRoom({ roomId: 99, name: 'New Room' });
const result = roomsReducer(state, { type: Types.UPDATE_ROOMS, rooms: [room] });
const room = makeRoom({ roomId: 99, name: 'New Room' }).info;
const result = roomsReducer(state, Actions.updateRooms({ rooms: [room] }));
expect(result.rooms[99]).toBeDefined();
expect(result.rooms[99].name).toBe('New Room');
expect(result.rooms[99].info.name).toBe('New Room');
});
});
// ── JOIN_ROOM ──────────────────────────────────────────────────────────────────
describe('JOIN_ROOM', () => {
it('copies gameList and userList, sorts both, sets joinedRoomIds', () => {
it('normalizes raw room into keyed games/users maps and marks joined', () => {
const state = makeRoomsState({ rooms: {}, joinedRoomIds: {} });
const roomInfo = makeRoom({
// JOIN_ROOM carries a raw proto Room with its gameList/userList populated
const rawRoom = makeRoom({
roomId: 2,
gameList: [makeGame({ gameId: 1 })],
gameList: [makeGame({ gameId: 1 }).info],
userList: [makeUser({ name: 'Zane' }), makeUser({ name: 'Alice' })],
});
const result = roomsReducer(state, { type: Types.JOIN_ROOM, roomInfo });
}).info;
const result = roomsReducer(state, Actions.joinRoom({ roomInfo: rawRoom }));
expect(result.joinedRoomIds[2]).toBe(true);
expect(result.rooms[2].userList[0].name).toBe('Alice');
expect(result.rooms[2]).toMatchObject({ roomId: 2 });
expect(result.rooms[2].users['Alice']).toBeDefined();
expect(result.rooms[2].users['Zane']).toBeDefined();
expect(result.rooms[2].games[1]).toBeDefined();
expect(result.rooms[2].info.roomId).toBe(2);
});
});
@ -90,7 +105,7 @@ describe('LEAVE_ROOM', () => {
joinedRoomIds: { 1: true },
messages: { 1: [makeMessage()] },
});
const result = roomsReducer(state, { type: Types.LEAVE_ROOM, roomId: 1 });
const result = roomsReducer(state, Actions.leaveRoom({ roomId: 1 }));
expect(result.joinedRoomIds[1]).toBeUndefined();
expect(result.messages[1]).toBeUndefined();
});
@ -102,7 +117,7 @@ describe('ADD_MESSAGE', () => {
it('appends message with timeReceived set', () => {
const state = makeRoomsState({ messages: { 1: [] } });
const message = makeMessage({ message: 'hello', timeReceived: 0 });
const result = roomsReducer(state, { type: Types.ADD_MESSAGE, roomId: 1, message });
const result = roomsReducer(state, Actions.addMessage({ roomId: 1, message }));
expect(result.messages[1]).toHaveLength(1);
expect(result.messages[1][0].timeReceived).toBeGreaterThan(0);
});
@ -110,7 +125,7 @@ describe('ADD_MESSAGE', () => {
it('creates message list for roomId when none exists', () => {
const state = makeRoomsState({ messages: {} });
const message = makeMessage();
const result = roomsReducer(state, { type: Types.ADD_MESSAGE, roomId: 5, message });
const result = roomsReducer(state, Actions.addMessage({ roomId: 5, message }));
expect(result.messages[5]).toHaveLength(1);
});
@ -121,7 +136,7 @@ describe('ADD_MESSAGE', () => {
);
const state = makeRoomsState({ messages: { 1: messages } });
const newMsg = makeMessage({ message: 'new' });
const result = roomsReducer(state, { type: Types.ADD_MESSAGE, roomId: 1, message: newMsg });
const result = roomsReducer(state, Actions.addMessage({ roomId: 1, message: newMsg }));
expect(result.messages[1]).toHaveLength(MAX_ROOM_MESSAGES);
expect(result.messages[1][0].message).not.toBe('first');
expect(result.messages[1][MAX_ROOM_MESSAGES - 1].message).toBe('new');
@ -130,14 +145,14 @@ describe('ADD_MESSAGE', () => {
it('prepends "name: " to message when name is present', () => {
const state = makeRoomsState({ messages: { 1: [] } });
const message = makeMessage({ name: 'Alice', message: 'hello' });
const result = roomsReducer(state, { type: Types.ADD_MESSAGE, roomId: 1, message });
const result = roomsReducer(state, Actions.addMessage({ roomId: 1, message }));
expect(result.messages[1][0].message).toBe('Alice: hello');
});
it('does not prepend when name is empty', () => {
const state = makeRoomsState({ messages: { 1: [] } });
const message = makeMessage({ name: '', message: 'system msg' });
const result = roomsReducer(state, { type: Types.ADD_MESSAGE, roomId: 1, message });
const result = roomsReducer(state, Actions.addMessage({ roomId: 1, message }));
expect(result.messages[1][0].message).toBe('system msg');
});
});
@ -145,94 +160,92 @@ describe('ADD_MESSAGE', () => {
// ── UPDATE_GAMES ──────────────────────────────────────────────────────────────
describe('UPDATE_GAMES', () => {
it('removes closed games from gameList', () => {
const room = makeRoom({ roomId: 1, gameList: [makeGame({ gameId: 1 })] });
it('removes closed games from the keyed games map', () => {
const room = makeRoom({ roomId: 1, games: { 1: makeGame({ gameId: 1 }) } });
const state = makeRoomsState({ rooms: { 1: room } });
const result = roomsReducer(state, {
type: Types.UPDATE_GAMES,
const result = roomsReducer(state, Actions.updateGames({
roomId: 1,
games: [{ gameId: 1, closed: true }],
});
expect(result.rooms[1].gameList).toHaveLength(0);
}));
expect(result.rooms[1].games[1]).toBeUndefined();
});
it('merges update into existing game', () => {
it('merges update into existing game info', () => {
const game = makeGame({ gameId: 1, description: 'old' });
const room = makeRoom({ roomId: 1, gameList: [game] });
const room = makeRoom({ roomId: 1, games: { 1: game } });
const state = makeRoomsState({ rooms: { 1: room } });
const result = roomsReducer(state, {
type: Types.UPDATE_GAMES,
const result = roomsReducer(state, Actions.updateGames({
roomId: 1,
games: [{ gameId: 1, description: 'new' }],
});
expect(result.rooms[1].gameList[0].description).toBe('new');
}));
expect(result.rooms[1].games[1].info.description).toBe('new');
});
it('appends new game to list and sorts', () => {
const room = makeRoom({ roomId: 1, gameList: [] });
it('inserts new game into the keyed map', () => {
const room = makeRoom({ roomId: 1 });
const state = makeRoomsState({ rooms: { 1: room } });
const newGame = makeGame({ gameId: 99, description: 'extra' });
const result = roomsReducer(state, { type: Types.UPDATE_GAMES, roomId: 1, games: [newGame] });
expect(result.rooms[1].gameList).toHaveLength(1);
expect(result.rooms[1].gameList[0].gameId).toBe(99);
const newGame = makeGame({ gameId: 99, description: 'extra' }).info;
const result = roomsReducer(state, Actions.updateGames({ roomId: 1, games: [newGame] }));
expect(Object.keys(result.rooms[1].games)).toHaveLength(1);
expect(result.rooms[1].games[99]).toBeDefined();
expect(result.rooms[1].games[99].info.gameId).toBe(99);
});
it('preserves existing games not included in the update', () => {
const game1 = makeGame({ gameId: 1, description: 'untouched' });
const game2 = makeGame({ gameId: 2, description: 'old' });
const room = makeRoom({ roomId: 1, gameList: [game1, game2] });
const room = makeRoom({ roomId: 1, games: { 1: game1, 2: game2 } });
const state = makeRoomsState({ rooms: { 1: room } });
const result = roomsReducer(state, {
type: Types.UPDATE_GAMES,
const result = roomsReducer(state, Actions.updateGames({
roomId: 1,
games: [{ gameId: 2, description: 'new' }],
});
expect(result.rooms[1].gameList.find(g => g.gameId === 1).description).toBe('untouched');
expect(result.rooms[1].gameList.find(g => g.gameId === 2).description).toBe('new');
}));
expect(result.rooms[1].games[1].info.description).toBe('untouched');
expect(result.rooms[1].games[2].info.description).toBe('new');
});
it('returns state identity when roomId is unknown', () => {
const state = makeRoomsState({ rooms: {} });
const result = roomsReducer(state, { type: Types.UPDATE_GAMES, roomId: 999, games: [] });
expect(result).toBe(state);
const result = roomsReducer(state, Actions.updateGames({ roomId: 999, games: [] }));
expect(result).toEqual(state);
});
});
// ── USER_JOINED / USER_LEFT ───────────────────────────────────────────────────
describe('USER_JOINED', () => {
it('appends user to userList and sorts by name ASC', () => {
const room = makeRoom({ roomId: 1, userList: [makeUser({ name: 'Zane' })] });
it('inserts user into the keyed users map', () => {
const room = makeRoom({ roomId: 1, users: { Zane: makeUser({ name: 'Zane' }) } });
const state = makeRoomsState({ rooms: { 1: room } });
const result = roomsReducer(state, { type: Types.USER_JOINED, roomId: 1, user: makeUser({ name: 'Alice' }) });
expect(result.rooms[1].userList[0].name).toBe('Alice');
expect(result.rooms[1].userList).toHaveLength(2);
const result = roomsReducer(state, Actions.userJoined({ roomId: 1, user: makeUser({ name: 'Alice' }) }));
expect(result.rooms[1].users['Alice']).toBeDefined();
expect(result.rooms[1].users['Zane']).toBeDefined();
expect(Object.keys(result.rooms[1].users)).toHaveLength(2);
});
});
describe('USER_LEFT', () => {
it('removes user by name from userList', () => {
const room = makeRoom({ roomId: 1, userList: [makeUser({ name: 'Alice' }), makeUser({ name: 'Bob' })] });
it('removes user by name from the keyed users map', () => {
const room = makeRoom({
roomId: 1,
users: { Alice: makeUser({ name: 'Alice' }), Bob: makeUser({ name: 'Bob' }) },
});
const state = makeRoomsState({ rooms: { 1: room } });
const result = roomsReducer(state, { type: Types.USER_LEFT, roomId: 1, name: 'Alice' });
expect(result.rooms[1].userList).toHaveLength(1);
expect(result.rooms[1].userList[0].name).toBe('Bob');
const result = roomsReducer(state, Actions.userLeft({ roomId: 1, name: 'Alice' }));
expect(result.rooms[1].users['Alice']).toBeUndefined();
expect(result.rooms[1].users['Bob']).toBeDefined();
});
});
// ── SORT_GAMES ────────────────────────────────────────────────────────────────
describe('SORT_GAMES', () => {
it('resorts gameList and updates sortGamesBy on state', () => {
const games = [makeGame({ gameId: 2 }), makeGame({ gameId: 1 })];
const room = makeRoom({ roomId: 1, gameList: games });
const state = makeRoomsState({ rooms: { 1: room } });
const result = roomsReducer(state, {
type: Types.SORT_GAMES,
roomId: 1,
it('updates sortGamesBy on state (sorting itself is now derived in selectors)', () => {
const state = makeRoomsState({ rooms: {} });
const result = roomsReducer(state, Actions.sortGames({
field: App.GameSortField.START_TIME,
order: App.SortDirection.ASC,
});
}));
expect(result.sortGamesBy).toEqual({ field: App.GameSortField.START_TIME, order: App.SortDirection.ASC });
});
});
@ -247,7 +260,7 @@ describe('REMOVE_MESSAGES', () => {
makeMessage({ message: 'Alice: world' }),
];
const state = makeRoomsState({ messages: { 1: msgs } });
const result = roomsReducer(state, { type: Types.REMOVE_MESSAGES, roomId: 1, name: 'Alice', amount: 1 });
const result = roomsReducer(state, Actions.removeMessages({ roomId: 1, name: 'Alice', amount: 1 }));
// reverse scan: removes LAST 'Alice:' message first, stops after 1
const remaining = result.messages[1];
expect(remaining).toHaveLength(2);
@ -263,7 +276,7 @@ describe('REMOVE_MESSAGES', () => {
makeMessage({ message: 'Alice: three' }),
];
const state = makeRoomsState({ messages: { 1: msgs } });
const result = roomsReducer(state, { type: Types.REMOVE_MESSAGES, roomId: 1, name: 'Alice', amount: 2 });
const result = roomsReducer(state, Actions.removeMessages({ roomId: 1, name: 'Alice', amount: 2 }));
const remaining = result.messages[1];
expect(remaining).toHaveLength(1);
});
@ -275,7 +288,7 @@ describe('REMOVE_MESSAGES', () => {
makeMessage({ message: 'Alice: c' }),
];
const state = makeRoomsState({ messages: { 1: msgs } });
const result = roomsReducer(state, { type: Types.REMOVE_MESSAGES, roomId: 1, name: 'Alice', amount: 1 });
const result = roomsReducer(state, Actions.removeMessages({ roomId: 1, name: 'Alice', amount: 1 }));
expect(result.messages[1]).toHaveLength(2);
});
});
@ -285,8 +298,8 @@ describe('REMOVE_MESSAGES', () => {
describe('GAME_CREATED', () => {
it('returns state unchanged', () => {
const state = makeRoomsState();
const result = roomsReducer(state, { type: Types.GAME_CREATED, roomId: 1 });
expect(result).toBe(state);
const result = roomsReducer(state, Actions.gameCreated({ roomId: 1 }));
expect(result).toEqual(state);
});
});
@ -295,13 +308,13 @@ describe('GAME_CREATED', () => {
describe('JOINED_GAME', () => {
it('sets joinedGameIds[roomId][gameId] = true', () => {
const state = makeRoomsState({ joinedGameIds: {} });
const result = roomsReducer(state, { type: Types.JOINED_GAME, roomId: 1, gameId: 5 });
const result = roomsReducer(state, Actions.joinedGame({ roomId: 1, gameId: 5 }));
expect(result.joinedGameIds[1][5]).toBe(true);
});
it('preserves other roomId entries in joinedGameIds', () => {
const state = makeRoomsState({ joinedGameIds: { 2: { 9: true } } });
const result = roomsReducer(state, { type: Types.JOINED_GAME, roomId: 1, gameId: 5 });
const result = roomsReducer(state, Actions.joinedGame({ roomId: 1, gameId: 5 }));
expect(result.joinedGameIds[2][9]).toBe(true);
expect(result.joinedGameIds[1][5]).toBe(true);
});

View file

@ -1,16 +1,14 @@
import * as _ from 'lodash';
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { App, Data, Enriched } from '@app/types';
import { App, Enriched } from '@app/types';
import { normalizeGameObject, normalizeGametypeMap, normalizeRoomInfo, normalizeUserMessage } from '../common';
import { normalizeGameObject, normalizeGametypeMap, normalizeRoomInfo, normalizeUserMessage, SortUtil } from '../common';
import { RoomsAction } from './rooms.actions';
import { RoomsState } from './rooms.interfaces'
import { MAX_ROOM_MESSAGES, Types } from './rooms.types';
export const MAX_ROOM_MESSAGES = 1000;
const initialState: RoomsState = {
rooms: {},
games: {},
joinedRoomIds: {},
joinedGameIds: {},
messages: {},
@ -24,316 +22,163 @@ const initialState: RoomsState = {
}
};
export const roomsReducer = (state = initialState, action: RoomsAction) => {
switch (action.type) {
case Types.CLEAR_STORE: {
return {
...initialState
};
}
export const roomsSlice = createSlice({
name: 'rooms',
initialState,
reducers: {
clearStore: () => initialState,
case Types.UPDATE_ROOMS: {
const rooms = {
...state.rooms
};
updateRooms: (state, action: PayloadAction<{ rooms: Data.ServerInfo_Room[] }>) => {
const { rooms } = action.payload;
// Server does not send everything on updates — preserve existing gameList/userList
_.each(action.rooms, (rawRoom, order) => {
const { gameList: _g, gametypeList, userList: _u, ...roomMeta } = rawRoom;
const { roomId } = roomMeta;
const existing = rooms[roomId] || {};
// UPDATE_ROOMS carries metadata only. For existing rooms, replace
// `info`, `gametypeMap` and `order`; preserve the normalized `games`
// and `users` maps (those are maintained by their own events).
rooms.forEach((rawRoom, order) => {
const { roomId } = rawRoom;
const existing = state.rooms[roomId];
const gametypeMap = normalizeGametypeMap(rawRoom.gametypeList);
const gametypeMap = normalizeGametypeMap(gametypeList);
rooms[roomId] = {
...(existing as Enriched.Room),
...roomMeta,
gametypeMap,
gameList: (existing as Enriched.Room).gameList,
userList: (existing as Enriched.Room).userList,
order,
};
if (existing) {
existing.info = rawRoom;
existing.gametypeMap = gametypeMap;
existing.order = order;
} else {
state.rooms[roomId] = {
info: rawRoom,
gametypeMap,
order,
games: {},
users: {},
};
}
});
},
return { ...state, rooms };
}
joinRoom: (state, action: PayloadAction<{ roomInfo: Data.ServerInfo_Room }>) => {
const { roomInfo: rawRoomInfo } = action.payload;
case Types.JOIN_ROOM: {
const { roomInfo: rawRoomInfo } = action;
const { joinedRoomIds, rooms, sortGamesBy, sortUsersBy } = state;
const roomEntry = normalizeRoomInfo(rawRoomInfo);
const roomId = roomEntry.info.roomId;
const roomInfo = normalizeRoomInfo(rawRoomInfo);
const { roomId } = roomInfo;
state.rooms[roomId] = roomEntry;
state.joinedRoomIds[roomId] = true;
},
const gameList = [
...roomInfo.gameList
];
leaveRoom: (state, action: PayloadAction<{ roomId: number }>) => {
const { roomId } = action.payload;
const userList = [
...roomInfo.userList
];
delete state.joinedRoomIds[roomId];
delete state.messages[roomId];
},
SortUtil.sortByField(gameList, sortGamesBy);
SortUtil.sortUsersByField(userList, sortUsersBy);
return {
...state,
rooms: {
...rooms,
[roomId]: {
...roomInfo,
gameList,
userList
}
},
joinedRoomIds: {
...joinedRoomIds,
[roomId]: true
},
}
}
case Types.LEAVE_ROOM: {
const { roomId } = action;
const { joinedRoomIds, messages } = state;
const _joined = {
...joinedRoomIds
};
const _messages = {
...messages
};
delete _joined[roomId];
delete _messages[roomId];
return {
...state,
joinedRoomIds: _joined,
messages: _messages,
}
}
case Types.ADD_MESSAGE: {
const { roomId, message } = action;
const { messages } = state;
let roomMessages = [...(messages[roomId] || [])];
if (roomMessages.length === MAX_ROOM_MESSAGES) {
roomMessages.shift();
}
addMessage: (state, action: PayloadAction<{ roomId: number; message: Enriched.Message }>) => {
const { roomId, message } = action.payload;
const existing = state.messages[roomId] ?? [];
const normalized = normalizeUserMessage({ ...message, timeReceived: Date.now() });
roomMessages.push(normalized);
const next =
existing.length >= MAX_ROOM_MESSAGES
? [...existing.slice(existing.length - MAX_ROOM_MESSAGES + 1), normalized]
: [...existing, normalized];
return {
...state,
messages: {
...messages,
state.messages[roomId] = next;
},
[roomId]: [
...roomMessages
]
}
}
}
// @TODO improve this reducer, likely by improving the store model
updateGames: (state, action: PayloadAction<{ roomId: number; games: Data.ServerInfo_Game[] }>) => {
const { roomId, games } = action.payload;
const room = state.rooms[roomId];
case Types.UPDATE_GAMES: {
const { roomId, games } = action;
const { rooms, sortGamesBy } = state;
const room = rooms[roomId];
// An empty gameList means no game updates — skip to avoid
// overwriting the existing game list with an empty one.
// An empty games array means no game updates — skip to avoid
// accidentally wiping the existing normalized games map.
if (!room || !games?.length) {
return state;
return;
}
// Normalize incoming raw proto games using the room's gametypeMap
const gametypeMap = room.gametypeMap ?? {};
const normalizedGames = games.map(g => normalizeGameObject(g, gametypeMap));
// Create map of games with update objects
const toUpdate = normalizedGames.reduce((map, game) => {
map[game.gameId] = game;
return map;
}, {});
const gameUpdates = room.gameList
// filter out closed games and remove from update map
.filter(game => {
const gameUpdate = toUpdate[game.gameId];
const closedGame = gameUpdate && gameUpdate.closed;
if (closedGame) {
delete toUpdate[game.gameId];
}
return !closedGame;
})
.map(game => {
const gameUpdate = toUpdate[game.gameId];
if (gameUpdate) {
delete toUpdate[game.gameId];
return {
...game,
...gameUpdate
};
}
return game;
});
// Push new games to end of list
if (_.size(toUpdate)) {
_.each(toUpdate, game => gameUpdates.push(game));
}
const gameList = [...gameUpdates];
SortUtil.sortByField(gameList, sortGamesBy);
return {
...state,
rooms: {
...rooms,
[roomId]: {
...room,
gameList
}
for (const rawGame of games) {
if (rawGame.closed) {
delete room.games[rawGame.gameId];
continue;
}
const existing = room.games[rawGame.gameId];
if (existing) {
// Merge the incoming proto into the existing snapshot.
const merged: Data.ServerInfo_Game = { ...existing.info, ...rawGame };
room.games[rawGame.gameId] = {
info: merged,
gameType: merged.gameTypes?.length
? (gametypeMap[merged.gameTypes[0]] ?? '')
: existing.gameType,
};
} else {
room.games[rawGame.gameId] = normalizeGameObject(rawGame, gametypeMap);
}
}
}
},
case Types.USER_JOINED: {
const { roomId, user } = action;
const { rooms, sortUsersBy } = state;
const room = { ...rooms[roomId] };
const userList = [
...room.userList,
user
];
SortUtil.sortUsersByField(userList, sortUsersBy);
return {
...state,
rooms: {
...rooms,
[roomId]: {
...room,
userList
}
}
};
}
case Types.USER_LEFT: {
const { roomId, name } = action;
const { rooms } = state;
const room = { ...rooms[roomId] };
const userList = room.userList.filter(user => user.name !== name);
return {
...state,
rooms: {
...rooms,
[roomId]: {
...room,
userList
}
}
};
}
case Types.SORT_GAMES: {
const { field, order, roomId } = action;
const { rooms } = state;
const gameList = [...rooms[roomId].gameList];
const sortGamesBy = {
field, order
};
SortUtil.sortByField(gameList, sortGamesBy);
return {
...state,
rooms: {
...rooms,
[roomId]: {
...rooms[roomId],
gameList
}
},
sortGamesBy
userJoined: (state, action: PayloadAction<{ roomId: number; user: Data.ServerInfo_User }>) => {
const { roomId, user } = action.payload;
const room = state.rooms[roomId];
if (!room) {
return;
}
}
case Types.REMOVE_MESSAGES: {
const { name, amount, roomId } = action;
const { messages } = state;
let amountRemoved = 0;
room.users[user.name] = user;
},
return {
...state,
messages: {
...messages,
[roomId]: messages[roomId]
.reverse()
.filter(({ message }) => {
if (amount === amountRemoved) {
return true;
}
userLeft: (state, action: PayloadAction<{ roomId: number; name: string }>) => {
const { roomId, name } = action.payload;
const room = state.rooms[roomId];
if (!room) {
return;
}
const keep = message.indexOf(`${name}:`) !== 0;
delete room.users[name];
},
if (!keep) {
amountRemoved++;
}
sortGames: (state, action: PayloadAction<{ field: App.GameSortField; order: App.SortDirection }>) => {
// Sort is now derived in selectors; the reducer only stores the sort config.
const { field, order } = action.payload;
state.sortGamesBy = { field, order };
},
return keep;
})
.reverse()
removeMessages: (state, action: PayloadAction<{ roomId: number; name: string; amount: number }>) => {
const { name, amount, roomId } = action.payload;
const roomMessages = state.messages[roomId];
if (!roomMessages) {
return;
}
// Drop the `amount` most-recent messages whose text starts with `${name}:`.
// Walk newest → oldest so we remove the N latest matches.
const prefix = `${name}:`;
const keep = new Array(roomMessages.length).fill(true);
let remaining = amount;
for (let i = roomMessages.length - 1; i >= 0 && remaining > 0; i--) {
if (roomMessages[i].message.indexOf(prefix) === 0) {
keep[i] = false;
remaining--;
}
}
}
case Types.JOINED_GAME: {
const { gameId, roomId } = action;
const { joinedGameIds } = state;
state.messages[roomId] = roomMessages.filter((_, i) => keep[i]);
},
return {
...state,
joinedGameIds: {
...joinedGameIds,
[roomId]: {
...joinedGameIds[roomId],
[gameId]: true,
}
}
joinedGame: (state, action: PayloadAction<{ gameId: number; roomId: number }>) => {
const { gameId, roomId } = action.payload;
if (!state.joinedGameIds[roomId]) {
state.joinedGameIds[roomId] = {};
}
}
state.joinedGameIds[roomId][gameId] = true;
},
// Signal-only — no state mutation needed; explicit for discriminated-union exhaustiveness
case Types.GAME_CREATED:
return state;
gameCreated: (_state, _action: PayloadAction<{ roomId: number }>) => {},
},
});
default:
return state;
}
}
export const roomsReducer = roomsSlice.reducer;

View file

@ -12,11 +12,6 @@ describe('Selectors', () => {
expect(Selectors.getRooms(rootState(state))).toBe(state.rooms);
});
it('getGames → returns games map', () => {
const state = makeRoomsState({ games: { 1: { 1: makeGame() } } });
expect(Selectors.getGames(rootState(state))).toBe(state.games);
});
it('getRoom → returns room matching roomId', () => {
const room = makeRoom({ roomId: 1 });
const state = makeRoomsState({ rooms: { 1: room } });
@ -76,8 +71,9 @@ describe('Selectors', () => {
it('getJoinedGames → returns only games whose gameId is in joinedGameIds for that room', () => {
const game1 = makeGame({ gameId: 1 });
const game2 = makeGame({ gameId: 2 });
const room = makeRoom({ roomId: 1, games: { 1: game1, 2: game2 } });
const state = makeRoomsState({
games: { 1: { 1: game1, 2: game2 } },
rooms: { 1: room },
joinedGameIds: { 1: { 1: true } },
});
const result = Selectors.getJoinedGames(rootState(state), 1);
@ -85,21 +81,52 @@ describe('Selectors', () => {
expect(result[0]).toBe(game1);
});
it('getJoinedGames → returns empty array when room is unknown', () => {
const state = makeRoomsState({ rooms: {}, joinedGameIds: { 1: { 1: true } } });
expect(Selectors.getJoinedGames(rootState(state), 1)).toHaveLength(0);
});
it('getRoomMessages → returns messages array for roomId', () => {
const messages = [makeMessage()];
const state = makeRoomsState({ messages: { 1: messages } });
expect(Selectors.getRoomMessages(rootState(state), 1)).toBe(messages);
});
it('getRoomGames → returns gameList for roomId', () => {
const room = makeRoom({ roomId: 1, gameList: [makeGame()] });
it('getRoomGames → returns keyed games map for roomId', () => {
const game = makeGame({ gameId: 10 });
const room = makeRoom({ roomId: 1, games: { 10: game } });
const state = makeRoomsState({ rooms: { 1: room } });
expect(Selectors.getRoomGames(rootState(state), 1)).toBe(room.gameList);
expect(Selectors.getRoomGames(rootState(state), 1)).toBe(room.games);
});
it('getRoomUsers → returns userList for roomId', () => {
const room = makeRoom({ roomId: 1, userList: [makeUser()] });
it('getRoomGames → returns EMPTY_GAMES_MAP for unknown roomId', () => {
const state = makeRoomsState({ rooms: {} });
expect(Selectors.getRoomGames(rootState(state), 999)).toEqual({});
});
it('getRoomUsers → returns keyed users map for roomId', () => {
const user = makeUser({ name: 'alice' });
const room = makeRoom({ roomId: 1, users: { alice: user } });
const state = makeRoomsState({ rooms: { 1: room } });
expect(Selectors.getRoomUsers(rootState(state), 1)).toBe(room.userList);
expect(Selectors.getRoomUsers(rootState(state), 1)).toBe(room.users);
});
it('getSortedRoomGames → returns sorted array view of games map', () => {
const game1 = makeGame({ gameId: 1, description: 'beta' });
const game2 = makeGame({ gameId: 2, description: 'alpha' });
const room = makeRoom({ roomId: 1, games: { 1: game1, 2: game2 } });
const state = makeRoomsState({ rooms: { 1: room } });
const result = Selectors.getSortedRoomGames(rootState(state), 1);
expect(result).toHaveLength(2);
});
it('getSortedRoomUsers → returns sorted user array sorted by name', () => {
const zane = makeUser({ name: 'Zane' });
const alice = makeUser({ name: 'Alice' });
const room = makeRoom({ roomId: 1, users: { Zane: zane, Alice: alice } });
const state = makeRoomsState({ rooms: { 1: room } });
const result = Selectors.getSortedRoomUsers(rootState(state), 1);
expect(result[0].name).toBe('Alice');
expect(result[1].name).toBe('Zane');
});
});

View file

@ -1,16 +1,20 @@
import * as _ from 'lodash';
import { createSelector } from '@reduxjs/toolkit';
import { Data, Enriched } from '@app/types';
import { SortUtil } from '../common';
import { RoomsState } from './rooms.interfaces';
interface State {
rooms: RoomsState
}
const EMPTY_GAMES: Enriched.Game[] = [];
const EMPTY_USERS: Data.ServerInfo_User[] = [];
const EMPTY_GAMES_MAP: { [id: number]: Enriched.Game } = {};
const EMPTY_USERS_MAP: { [name: string]: Data.ServerInfo_User } = {};
export const Selectors = {
getRooms: ({ rooms }: State) => rooms.rooms,
getGames: ({ rooms }: State) => rooms.games,
getRoom: ({ rooms }: State, id: number) =>
_.find(rooms.rooms, ({ roomId }) => roomId === id),
getRoom: ({ rooms }: State, id: number) => rooms.rooms[id],
getJoinedRoomIds: ({ rooms }: State) => rooms.joinedRoomIds,
getJoinedGameIds: ({ rooms }: State) => rooms.joinedGameIds,
getMessages: ({ rooms }: State) => rooms.messages,
@ -19,17 +23,60 @@ export const Selectors = {
getJoinedRooms: createSelector(
[(state: State) => state.rooms.rooms, (state: State) => state.rooms.joinedRoomIds],
(rooms, joined) => _.filter(rooms, room => joined[room.roomId])
(rooms, joined) => Object.values(rooms).filter(room => joined[room.info.roomId])
),
getJoinedGames: createSelector(
[(state: State, roomId: number) => state.rooms.games[roomId], (state: State, roomId: number) => state.rooms.joinedGameIds[roomId]],
(games, joined) => _.filter(games, game => joined[game.gameId])
/**
* Returns games in the given room that the local client has joined.
* Reads from the room's normalized `games` map fixes the pre-existing
* bug where this selector read from a never-populated top-level `games` field.
*/
getJoinedGames: (state: State, roomId: number): Enriched.Game[] => {
const room = state.rooms.rooms[roomId];
const joined = state.rooms.joinedGameIds[roomId];
if (!room || !joined) {
return EMPTY_GAMES;
}
return Object.values(room.games).filter(game => joined[game.info.gameId]);
},
getRoomMessages: (state: State, roomId: number) => state.rooms.messages[roomId],
/** Raw keyed games map for a room. For a sorted array, use `getSortedRoomGames`. */
getRoomGames: (state: State, roomId: number) => state.rooms.rooms[roomId]?.games ?? EMPTY_GAMES_MAP,
/** Raw keyed users map for a room. For a sorted array, use `getSortedRoomUsers`. */
getRoomUsers: (state: State, roomId: number) => state.rooms.rooms[roomId]?.users ?? EMPTY_USERS_MAP,
/**
* Sorted array view of a room's games for display. Memoized by the input
* references recomputes only when the games map, gametypeMap, or sort
* config actually change.
*/
getSortedRoomGames: createSelector(
[
(state: State, roomId: number) => state.rooms.rooms[roomId]?.games,
(state: State) => state.rooms.sortGamesBy,
],
(games, sortBy): Enriched.Game[] => {
if (!games) {
return EMPTY_GAMES;
}
return SortUtil.sortedByField(Object.values(games), sortBy);
}
),
getRoomMessages: (state: State, roomId: number) => Selectors.getMessages(state)[roomId],
getRoomGames: (state: State, roomId: number) => Selectors.getRooms(state)[roomId].gameList,
getRoomUsers: (state: State, roomId: number) => Selectors.getRooms(state)[roomId].userList
/** Sorted array view of a room's users for display. */
getSortedRoomUsers: createSelector(
[
(state: State, roomId: number) => state.rooms.rooms[roomId]?.users,
(state: State) => state.rooms.sortUsersBy,
],
(users, sortBy): Data.ServerInfo_User[] => {
if (!users) {
return EMPTY_USERS;
}
return SortUtil.sortedUsersByField(Object.values(users), sortBy);
}
),
}

View file

@ -1,16 +1,20 @@
import { roomsSlice } from './rooms.reducer';
const a = roomsSlice.actions;
export const Types = {
CLEAR_STORE: '[Rooms] Clear Store',
UPDATE_ROOMS: '[Rooms] Update Rooms',
JOIN_ROOM: '[Rooms] Join Room',
LEAVE_ROOM: '[Rooms] Leave Room',
ADD_MESSAGE: '[Rooms] Add Message',
UPDATE_GAMES: '[Rooms] Update Games',
USER_JOINED: '[Rooms] User Joined',
USER_LEFT: '[Rooms] User Left',
SORT_GAMES: '[Rooms] Sort Games',
REMOVE_MESSAGES: '[Rooms] Remove Messages',
GAME_CREATED: '[Rooms] Game Created',
JOINED_GAME: '[Rooms] Joined Game',
CLEAR_STORE: a.clearStore.type,
UPDATE_ROOMS: a.updateRooms.type,
JOIN_ROOM: a.joinRoom.type,
LEAVE_ROOM: a.leaveRoom.type,
ADD_MESSAGE: a.addMessage.type,
UPDATE_GAMES: a.updateGames.type,
USER_JOINED: a.userJoined.type,
USER_LEFT: a.userLeft.type,
SORT_GAMES: a.sortGames.type,
REMOVE_MESSAGES: a.removeMessages.type,
GAME_CREATED: a.gameCreated.type,
JOINED_GAME: a.joinedGame.type,
} as const;
export const MAX_ROOM_MESSAGES = 1000;
export { MAX_ROOM_MESSAGES } from './rooms.reducer';

View file

@ -104,8 +104,16 @@ export function makeReplayMatch(
});
}
export function makeGame(overrides: Partial<Enriched.Game> = {}): Enriched.Game {
return { ...create(Data.ServerInfo_GameSchema, { description: '' }), gameType: '', ...overrides };
type MakeGameOverrides = MessageInitShape<typeof Data.ServerInfo_GameSchema> & {
gameType?: string;
};
export function makeGame(overrides: MakeGameOverrides = {}): Enriched.Game {
const { gameType = '', ...protoFields } = overrides;
return {
info: create(Data.ServerInfo_GameSchema, { description: '', ...protoFields }),
gameType,
};
}
export function makeLoginSuccessContext(
@ -131,8 +139,8 @@ export function makePendingActivationContext(
export function makeServerState(overrides: Partial<ServerState> = {}): ServerState {
return {
initialized: false,
buddyList: [],
ignoreList: [],
buddyList: {},
ignoreList: {},
status: {
connectionAttemptMade: false,
state: App.StatusEnum.DISCONNECTED,
@ -149,7 +157,7 @@ export function makeServerState(overrides: Partial<ServerState> = {}): ServerSta
chat: [],
},
user: null,
users: [],
users: {},
sortUsersBy: {
field: App.UserSortField.NAME,
order: App.SortDirection.ASC,
@ -164,7 +172,7 @@ export function makeServerState(overrides: Partial<ServerState> = {}): ServerSta
warnListOptions: [],
warnUser: '',
adminNotes: {},
replays: [],
replays: {},
backendDecks: null,
gamesOfUser: {},
registrationError: null,

View file

@ -16,349 +16,348 @@ import {
describe('Actions', () => {
it('initialized', () => {
expect(Actions.initialized()).toEqual({ type: Types.INITIALIZED });
expect(Actions.initialized()).toEqual({ type: Types.INITIALIZED, payload: undefined });
});
it('clearStore', () => {
expect(Actions.clearStore()).toEqual({ type: Types.CLEAR_STORE });
expect(Actions.clearStore()).toEqual({ type: Types.CLEAR_STORE, payload: undefined });
});
it('connectionAttempted', () => {
expect(Actions.connectionAttempted()).toEqual({ type: Types.CONNECTION_ATTEMPTED });
expect(Actions.connectionAttempted()).toEqual({ type: Types.CONNECTION_ATTEMPTED, payload: undefined });
});
it('loginSuccessful', () => {
const options = makeLoginSuccessContext();
expect(Actions.loginSuccessful(options)).toEqual({ type: Types.LOGIN_SUCCESSFUL, options });
expect(Actions.loginSuccessful({ options })).toEqual({ type: Types.LOGIN_SUCCESSFUL, payload: { options } });
});
it('loginFailed', () => {
expect(Actions.loginFailed()).toEqual({ type: Types.LOGIN_FAILED });
expect(Actions.loginFailed()).toEqual({ type: Types.LOGIN_FAILED, payload: undefined });
});
it('connectionFailed', () => {
expect(Actions.connectionFailed()).toEqual({ type: Types.CONNECTION_FAILED });
expect(Actions.connectionFailed()).toEqual({ type: Types.CONNECTION_FAILED, payload: undefined });
});
it('testConnectionSuccessful', () => {
expect(Actions.testConnectionSuccessful()).toEqual({ type: Types.TEST_CONNECTION_SUCCESSFUL });
expect(Actions.testConnectionSuccessful()).toEqual({ type: Types.TEST_CONNECTION_SUCCESSFUL, payload: undefined });
});
it('testConnectionFailed', () => {
expect(Actions.testConnectionFailed()).toEqual({ type: Types.TEST_CONNECTION_FAILED });
expect(Actions.testConnectionFailed()).toEqual({ type: Types.TEST_CONNECTION_FAILED, payload: undefined });
});
it('serverMessage', () => {
expect(Actions.serverMessage('hello')).toEqual({ type: Types.SERVER_MESSAGE, message: 'hello' });
expect(Actions.serverMessage({ message: 'hello' })).toEqual({ type: Types.SERVER_MESSAGE, payload: { message: 'hello' } });
});
it('updateBuddyList', () => {
const list = [makeUser()];
expect(Actions.updateBuddyList(list)).toEqual({ type: Types.UPDATE_BUDDY_LIST, buddyList: list });
expect(Actions.updateBuddyList({ buddyList: list })).toEqual({ type: Types.UPDATE_BUDDY_LIST, payload: { buddyList: list } });
});
it('addToBuddyList', () => {
const user = makeUser();
expect(Actions.addToBuddyList(user)).toEqual({ type: Types.ADD_TO_BUDDY_LIST, user });
expect(Actions.addToBuddyList({ user })).toEqual({ type: Types.ADD_TO_BUDDY_LIST, payload: { user } });
});
it('removeFromBuddyList', () => {
expect(Actions.removeFromBuddyList('Alice')).toEqual({ type: Types.REMOVE_FROM_BUDDY_LIST, userName: 'Alice' });
const action = Actions.removeFromBuddyList({ userName: 'Alice' });
expect(action).toEqual({ type: Types.REMOVE_FROM_BUDDY_LIST, payload: { userName: 'Alice' } });
});
it('updateIgnoreList', () => {
const list = [makeUser()];
expect(Actions.updateIgnoreList(list)).toEqual({ type: Types.UPDATE_IGNORE_LIST, ignoreList: list });
expect(Actions.updateIgnoreList({ ignoreList: list })).toEqual({ type: Types.UPDATE_IGNORE_LIST, payload: { ignoreList: list } });
});
it('addToIgnoreList', () => {
const user = makeUser();
expect(Actions.addToIgnoreList(user)).toEqual({ type: Types.ADD_TO_IGNORE_LIST, user });
expect(Actions.addToIgnoreList({ user })).toEqual({ type: Types.ADD_TO_IGNORE_LIST, payload: { user } });
});
it('removeFromIgnoreList', () => {
expect(Actions.removeFromIgnoreList('Bob')).toEqual({ type: Types.REMOVE_FROM_IGNORE_LIST, userName: 'Bob' });
const action = Actions.removeFromIgnoreList({ userName: 'Bob' });
expect(action).toEqual({ type: Types.REMOVE_FROM_IGNORE_LIST, payload: { userName: 'Bob' } });
});
it('updateInfo', () => {
const info = { name: 'Servatrice', version: '2.0' };
expect(Actions.updateInfo(info)).toEqual({ type: Types.UPDATE_INFO, info });
expect(Actions.updateInfo({ info })).toEqual({ type: Types.UPDATE_INFO, payload: { info } });
});
it('updateStatus', () => {
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, payload: { status } });
});
it('updateUser', () => {
const user = makeUser();
expect(Actions.updateUser(user)).toEqual({ type: Types.UPDATE_USER, user });
expect(Actions.updateUser({ user })).toEqual({ type: Types.UPDATE_USER, payload: { user } });
});
it('updateUsers', () => {
const users = [makeUser()];
expect(Actions.updateUsers(users)).toEqual({ type: Types.UPDATE_USERS, users });
expect(Actions.updateUsers({ users })).toEqual({ type: Types.UPDATE_USERS, payload: { users } });
});
it('userJoined', () => {
const user = makeUser();
expect(Actions.userJoined(user)).toEqual({ type: Types.USER_JOINED, user });
expect(Actions.userJoined({ user })).toEqual({ type: Types.USER_JOINED, payload: { user } });
});
it('userLeft', () => {
expect(Actions.userLeft('Carol')).toEqual({ type: Types.USER_LEFT, name: 'Carol' });
expect(Actions.userLeft({ name: 'Carol' })).toEqual({ type: Types.USER_LEFT, payload: { name: 'Carol' } });
});
it('viewLogs', () => {
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, payload: { logs } });
});
it('clearLogs', () => {
expect(Actions.clearLogs()).toEqual({ type: Types.CLEAR_LOGS });
expect(Actions.clearLogs()).toEqual({ type: Types.CLEAR_LOGS, payload: undefined });
});
it('registrationRequiresEmail', () => {
expect(Actions.registrationRequiresEmail()).toEqual({ type: Types.REGISTRATION_REQUIRES_EMAIL });
expect(Actions.registrationRequiresEmail()).toEqual({ type: Types.REGISTRATION_REQUIRES_EMAIL, payload: undefined });
});
it('registrationSuccess', () => {
expect(Actions.registrationSuccess()).toEqual({ type: Types.REGISTRATION_SUCCESS });
expect(Actions.registrationSuccess()).toEqual({ type: Types.REGISTRATION_SUCCESS, payload: undefined });
});
it('registrationFailed', () => {
expect(Actions.registrationFailed('err', 999)).toEqual({ type: Types.REGISTRATION_FAILED, reason: 'err', endTime: 999 });
const action = Actions.registrationFailed({ reason: 'err', endTime: 999 });
expect(action.payload).toEqual({ reason: 'err', endTime: 999 });
});
it('registrationFailed without endTime', () => {
expect(Actions.registrationFailed('err')).toEqual({ type: Types.REGISTRATION_FAILED, reason: 'err', endTime: undefined });
const action = Actions.registrationFailed({ reason: 'err' });
expect(action.payload).toEqual({ reason: 'err' });
});
it('registrationEmailError', () => {
expect(Actions.registrationEmailError('bad email')).toEqual({ type: Types.REGISTRATION_EMAIL_ERROR, error: 'bad email' });
const action = Actions.registrationEmailError({ error: 'bad email' });
expect(action.payload).toEqual({ error: 'bad email' });
});
it('registrationPasswordError', () => {
expect(Actions.registrationPasswordError('bad pw')).toEqual({ type: Types.REGISTRATION_PASSWORD_ERROR, error: 'bad pw' });
const action = Actions.registrationPasswordError({ error: 'bad pw' });
expect(action.payload).toEqual({ error: 'bad pw' });
});
it('registrationUserNameError', () => {
expect(Actions.registrationUserNameError('bad name')).toEqual({ type: Types.REGISTRATION_USERNAME_ERROR, error: 'bad name' });
const action = Actions.registrationUserNameError({ error: 'bad name' });
expect(action.payload).toEqual({ error: 'bad name' });
});
it('accountAwaitingActivation', () => {
const options = makePendingActivationContext();
expect(Actions.accountAwaitingActivation(options)).toEqual({ type: Types.ACCOUNT_AWAITING_ACTIVATION, options });
expect(Actions.accountAwaitingActivation({ options })).toEqual({ type: Types.ACCOUNT_AWAITING_ACTIVATION, payload: { options } });
});
it('accountActivationSuccess', () => {
expect(Actions.accountActivationSuccess()).toEqual({ type: Types.ACCOUNT_ACTIVATION_SUCCESS });
expect(Actions.accountActivationSuccess()).toEqual({ type: Types.ACCOUNT_ACTIVATION_SUCCESS, payload: undefined });
});
it('accountActivationFailed', () => {
expect(Actions.accountActivationFailed()).toEqual({ type: Types.ACCOUNT_ACTIVATION_FAILED });
expect(Actions.accountActivationFailed()).toEqual({ type: Types.ACCOUNT_ACTIVATION_FAILED, payload: undefined });
});
it('resetPassword', () => {
expect(Actions.resetPassword()).toEqual({ type: Types.RESET_PASSWORD_REQUESTED });
expect(Actions.resetPassword()).toEqual({ type: Types.RESET_PASSWORD_REQUESTED, payload: undefined });
});
it('resetPasswordFailed', () => {
expect(Actions.resetPasswordFailed()).toEqual({ type: Types.RESET_PASSWORD_FAILED });
expect(Actions.resetPasswordFailed()).toEqual({ type: Types.RESET_PASSWORD_FAILED, payload: undefined });
});
it('resetPasswordChallenge', () => {
expect(Actions.resetPasswordChallenge()).toEqual({ type: Types.RESET_PASSWORD_CHALLENGE });
expect(Actions.resetPasswordChallenge()).toEqual({ type: Types.RESET_PASSWORD_CHALLENGE, payload: undefined });
});
it('resetPasswordSuccess', () => {
expect(Actions.resetPasswordSuccess()).toEqual({ type: Types.RESET_PASSWORD_SUCCESS });
expect(Actions.resetPasswordSuccess()).toEqual({ type: Types.RESET_PASSWORD_SUCCESS, payload: undefined });
});
it('adjustMod', () => {
expect(Actions.adjustMod('Dan', true, false)).toEqual({
expect(Actions.adjustMod({ userName: 'Dan', shouldBeMod: true, shouldBeJudge: false })).toEqual({
type: Types.ADJUST_MOD,
userName: 'Dan',
shouldBeMod: true,
shouldBeJudge: false,
payload: { userName: 'Dan', shouldBeMod: true, shouldBeJudge: false },
});
});
it('reloadConfig', () => {
expect(Actions.reloadConfig()).toEqual({ type: Types.RELOAD_CONFIG });
expect(Actions.reloadConfig()).toEqual({ type: Types.RELOAD_CONFIG, payload: undefined });
});
it('shutdownServer', () => {
expect(Actions.shutdownServer()).toEqual({ type: Types.SHUTDOWN_SERVER });
expect(Actions.shutdownServer()).toEqual({ type: Types.SHUTDOWN_SERVER, payload: undefined });
});
it('updateServerMessage', () => {
expect(Actions.updateServerMessage()).toEqual({ type: Types.UPDATE_SERVER_MESSAGE });
expect(Actions.updateServerMessage()).toEqual({ type: Types.UPDATE_SERVER_MESSAGE, payload: undefined });
});
it('accountPasswordChange', () => {
expect(Actions.accountPasswordChange()).toEqual({ type: Types.ACCOUNT_PASSWORD_CHANGE });
expect(Actions.accountPasswordChange()).toEqual({ type: Types.ACCOUNT_PASSWORD_CHANGE, payload: undefined });
});
it('accountEditChanged', () => {
const user = makeUser();
expect(Actions.accountEditChanged(user)).toEqual({ type: Types.ACCOUNT_EDIT_CHANGED, user });
expect(Actions.accountEditChanged({ user })).toEqual({ type: Types.ACCOUNT_EDIT_CHANGED, payload: { user } });
});
it('accountImageChanged', () => {
const user = makeUser();
expect(Actions.accountImageChanged(user)).toEqual({ type: Types.ACCOUNT_IMAGE_CHANGED, user });
expect(Actions.accountImageChanged({ user })).toEqual({ type: Types.ACCOUNT_IMAGE_CHANGED, payload: { user } });
});
it('getUserInfo', () => {
const userInfo = makeUser({ name: 'Frank' });
expect(Actions.getUserInfo(userInfo)).toEqual({ type: Types.GET_USER_INFO, userInfo });
expect(Actions.getUserInfo({ userInfo })).toEqual({ type: Types.GET_USER_INFO, payload: { userInfo } });
});
it('notifyUser', () => {
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, payload: { notification } });
});
it('serverShutdown', () => {
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, payload: { data } });
});
it('userMessage', () => {
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, payload: { messageData } });
});
it('addToList', () => {
expect(Actions.addToList('buddyList', 'Grace')).toEqual({
expect(Actions.addToList({ list: 'buddyList', userName: 'Grace' })).toEqual({
type: Types.ADD_TO_LIST,
list: 'buddyList',
userName: 'Grace',
payload: { list: 'buddyList', userName: 'Grace' },
});
});
it('removeFromList', () => {
expect(Actions.removeFromList('buddyList', 'Hank')).toEqual({
expect(Actions.removeFromList({ list: 'buddyList', userName: 'Hank' })).toEqual({
type: Types.REMOVE_FROM_LIST,
list: 'buddyList',
userName: 'Hank',
payload: { list: 'buddyList', userName: 'Hank' },
});
});
it('banFromServer', () => {
expect(Actions.banFromServer('Ira')).toEqual({ type: Types.BAN_FROM_SERVER, userName: 'Ira' });
expect(Actions.banFromServer({ userName: 'Ira' })).toEqual({ type: Types.BAN_FROM_SERVER, payload: { userName: 'Ira' } });
});
it('banHistory', () => {
const history = [makeBanHistoryItem()];
expect(Actions.banHistory('Ira', history)).toEqual({ type: Types.BAN_HISTORY, userName: 'Ira', banHistory: history });
const action = Actions.banHistory({ userName: 'Ira', banHistory: history });
expect(action.payload).toEqual({ userName: 'Ira', banHistory: history });
});
it('warnHistory', () => {
const history = [makeWarnHistoryItem()];
expect(Actions.warnHistory('Jack', history)).toEqual({ type: Types.WARN_HISTORY, userName: 'Jack', warnHistory: history });
const action = Actions.warnHistory({ userName: 'Jack', warnHistory: history });
expect(action.payload).toEqual({ userName: 'Jack', warnHistory: history });
});
it('warnListOptions', () => {
const list = [makeWarnListItem()];
expect(Actions.warnListOptions(list)).toEqual({ type: Types.WARN_LIST_OPTIONS, warnList: list });
expect(Actions.warnListOptions({ warnList: list })).toEqual({ type: Types.WARN_LIST_OPTIONS, payload: { warnList: list } });
});
it('warnUser', () => {
expect(Actions.warnUser('Kelly')).toEqual({ type: Types.WARN_USER, userName: 'Kelly' });
expect(Actions.warnUser({ userName: 'Kelly' })).toEqual({ type: Types.WARN_USER, payload: { userName: 'Kelly' } });
});
it('grantReplayAccess', () => {
expect(Actions.grantReplayAccess(7, 'Moe')).toEqual({
expect(Actions.grantReplayAccess({ replayId: 7, moderatorName: 'Moe' })).toEqual({
type: Types.GRANT_REPLAY_ACCESS,
replayId: 7,
moderatorName: 'Moe',
payload: { replayId: 7, moderatorName: 'Moe' },
});
});
it('forceActivateUser', () => {
expect(Actions.forceActivateUser('Ned', 'Moe')).toEqual({
expect(Actions.forceActivateUser({ usernameToActivate: 'Ned', moderatorName: 'Moe' })).toEqual({
type: Types.FORCE_ACTIVATE_USER,
usernameToActivate: 'Ned',
moderatorName: 'Moe',
payload: { usernameToActivate: 'Ned', moderatorName: 'Moe' },
});
});
it('getAdminNotes', () => {
expect(Actions.getAdminNotes('Ned', 'some notes')).toEqual({
expect(Actions.getAdminNotes({ userName: 'Ned', notes: 'some notes' })).toEqual({
type: Types.GET_ADMIN_NOTES,
userName: 'Ned',
notes: 'some notes',
payload: { userName: 'Ned', notes: 'some notes' },
});
});
it('updateAdminNotes', () => {
expect(Actions.updateAdminNotes('Ned', 'updated notes')).toEqual({
expect(Actions.updateAdminNotes({ userName: 'Ned', notes: 'updated notes' })).toEqual({
type: Types.UPDATE_ADMIN_NOTES,
userName: 'Ned',
notes: 'updated notes',
payload: { userName: 'Ned', notes: 'updated notes' },
});
});
it('replayList', () => {
const list = [makeReplayMatch()];
expect(Actions.replayList(list)).toEqual({ type: Types.REPLAY_LIST, matchList: list });
expect(Actions.replayList({ matchList: list })).toEqual({ type: Types.REPLAY_LIST, payload: { matchList: list } });
});
it('replayAdded', () => {
const match = makeReplayMatch();
expect(Actions.replayAdded(match)).toEqual({ type: Types.REPLAY_ADDED, matchInfo: match });
expect(Actions.replayAdded({ matchInfo: match })).toEqual({ type: Types.REPLAY_ADDED, payload: { matchInfo: match } });
});
it('replayModifyMatch', () => {
expect(Actions.replayModifyMatch(5, true)).toEqual({
expect(Actions.replayModifyMatch({ gameId: 5, doNotHide: true })).toEqual({
type: Types.REPLAY_MODIFY_MATCH,
gameId: 5,
doNotHide: true,
payload: { gameId: 5, doNotHide: true },
});
});
it('replayDeleteMatch', () => {
expect(Actions.replayDeleteMatch(5)).toEqual({ type: Types.REPLAY_DELETE_MATCH, gameId: 5 });
expect(Actions.replayDeleteMatch({ gameId: 5 })).toEqual({ type: Types.REPLAY_DELETE_MATCH, payload: { gameId: 5 } });
});
it('backendDecks', () => {
const deckList = makeDeckList();
expect(Actions.backendDecks(deckList)).toEqual({ type: Types.BACKEND_DECKS, deckList });
expect(Actions.backendDecks({ deckList })).toEqual({ type: Types.BACKEND_DECKS, payload: { deckList } });
});
it('deckNewDir', () => {
expect(Actions.deckNewDir('a/b', 'newFolder')).toEqual({
expect(Actions.deckNewDir({ path: 'a/b', dirName: 'newFolder' })).toEqual({
type: Types.DECK_NEW_DIR,
path: 'a/b',
dirName: 'newFolder',
payload: { path: 'a/b', dirName: 'newFolder' },
});
});
it('deckDelDir', () => {
expect(Actions.deckDelDir('a/b')).toEqual({ type: Types.DECK_DEL_DIR, path: 'a/b' });
expect(Actions.deckDelDir({ path: 'a/b' })).toEqual({ type: Types.DECK_DEL_DIR, payload: { path: 'a/b' } });
});
it('deckUpload', () => {
const treeItem = makeDeckTreeItem();
expect(Actions.deckUpload('a/b', treeItem)).toEqual({
expect(Actions.deckUpload({ path: 'a/b', treeItem })).toEqual({
type: Types.DECK_UPLOAD,
path: 'a/b',
treeItem,
payload: { path: 'a/b', treeItem },
});
});
it('deckDelete', () => {
expect(Actions.deckDelete(42)).toEqual({ type: Types.DECK_DELETE, deckId: 42 });
expect(Actions.deckDelete({ deckId: 42 })).toEqual({ type: Types.DECK_DELETE, payload: { deckId: 42 } });
});
it('gamesOfUser', () => {
const response = create(Data.Response_GetGamesOfUserSchema, { roomList: [], gameList: [] });
expect(Actions.gamesOfUser('alice', response)).toEqual({ type: Types.GAMES_OF_USER, userName: 'alice', response });
const action = Actions.gamesOfUser({ userName: 'alice', response });
expect(action.payload).toEqual({ userName: 'alice', response });
});
it('clearRegistrationErrors', () => {
expect(Actions.clearRegistrationErrors()).toEqual({ type: Types.CLEAR_REGISTRATION_ERRORS });
expect(Actions.clearRegistrationErrors()).toEqual({ type: Types.CLEAR_REGISTRATION_ERRORS, payload: undefined });
});
});

View file

@ -1,245 +1,5 @@
import { Data, Enriched } from '@app/types';
import { ServerStateStatus } from './server.interfaces';
import { Types } from './server.types';
import { serverSlice } from './server.reducer';
export const Actions = {
initialized: () => ({
type: Types.INITIALIZED
}),
clearStore: () => ({
type: Types.CLEAR_STORE
}),
connectionAttempted: () => ({
type: Types.CONNECTION_ATTEMPTED
}),
loginSuccessful: (options: Enriched.LoginSuccessContext) => ({
type: Types.LOGIN_SUCCESSFUL,
options
}),
loginFailed: () => ({
type: Types.LOGIN_FAILED,
}),
connectionFailed: () => ({
type: Types.CONNECTION_FAILED,
}),
testConnectionSuccessful: () => ({
type: Types.TEST_CONNECTION_SUCCESSFUL,
}),
testConnectionFailed: () => ({
type: Types.TEST_CONNECTION_FAILED,
}),
serverMessage: (message: string) => ({
type: Types.SERVER_MESSAGE,
message
}),
updateBuddyList: (buddyList: Data.ServerInfo_User[]) => ({
type: Types.UPDATE_BUDDY_LIST,
buddyList
}),
addToBuddyList: (user: Data.ServerInfo_User) => ({
type: Types.ADD_TO_BUDDY_LIST,
user
}),
removeFromBuddyList: (userName: string) => ({
type: Types.REMOVE_FROM_BUDDY_LIST,
userName
}),
updateIgnoreList: (ignoreList: Data.ServerInfo_User[]) => ({
type: Types.UPDATE_IGNORE_LIST,
ignoreList
}),
addToIgnoreList: (user: Data.ServerInfo_User) => ({
type: Types.ADD_TO_IGNORE_LIST,
user
}),
removeFromIgnoreList: (userName: string) => ({
type: Types.REMOVE_FROM_IGNORE_LIST,
userName
}),
updateInfo: (info: { name: string; version: string }) => ({
type: Types.UPDATE_INFO,
info
}),
updateStatus: (status: Pick<ServerStateStatus, 'state' | 'description'>) => ({
type: Types.UPDATE_STATUS,
status
}),
updateUser: (user: Data.ServerInfo_User) => ({
type: Types.UPDATE_USER,
user
}),
updateUsers: (users: Data.ServerInfo_User[]) => ({
type: Types.UPDATE_USERS,
users
}),
userJoined: (user: Data.ServerInfo_User) => ({
type: Types.USER_JOINED,
user
}),
userLeft: (name: string) => ({
type: Types.USER_LEFT,
name
}),
viewLogs: (logs: Data.ServerInfo_ChatMessage[]) => ({
type: Types.VIEW_LOGS,
logs
}),
clearLogs: () => ({
type: Types.CLEAR_LOGS,
}),
registrationRequiresEmail: () => ({
type: Types.REGISTRATION_REQUIRES_EMAIL,
}),
registrationSuccess: () => ({
type: Types.REGISTRATION_SUCCESS,
}),
registrationFailed: (reason: string, endTime?: number) => ({
type: Types.REGISTRATION_FAILED,
reason,
endTime,
}),
registrationEmailError: (error: string) => ({
type: Types.REGISTRATION_EMAIL_ERROR,
error
}),
registrationPasswordError: (error: string) => ({
type: Types.REGISTRATION_PASSWORD_ERROR,
error
}),
registrationUserNameError: (error: string) => ({
type: Types.REGISTRATION_USERNAME_ERROR,
error
}),
clearRegistrationErrors: () => ({
type: Types.CLEAR_REGISTRATION_ERRORS,
}),
accountAwaitingActivation: (options: Enriched.PendingActivationContext) => ({
type: Types.ACCOUNT_AWAITING_ACTIVATION,
options
}),
accountActivationSuccess: () => ({
type: Types.ACCOUNT_ACTIVATION_SUCCESS,
}),
accountActivationFailed: () => ({
type: Types.ACCOUNT_ACTIVATION_FAILED,
}),
resetPassword: () => ({
type: Types.RESET_PASSWORD_REQUESTED,
}),
resetPasswordFailed: () => ({
type: Types.RESET_PASSWORD_FAILED,
}),
resetPasswordChallenge: () => ({
type: Types.RESET_PASSWORD_CHALLENGE,
}),
resetPasswordSuccess: () => ({
type: Types.RESET_PASSWORD_SUCCESS,
}),
adjustMod: (userName: string, shouldBeMod: boolean, shouldBeJudge: boolean) => ({
type: Types.ADJUST_MOD,
userName,
shouldBeMod,
shouldBeJudge,
}),
reloadConfig: () => ({
type: Types.RELOAD_CONFIG,
}),
shutdownServer: () => ({
type: Types.SHUTDOWN_SERVER,
}),
updateServerMessage: () => ({
type: Types.UPDATE_SERVER_MESSAGE,
}),
accountPasswordChange: () => ({
type: Types.ACCOUNT_PASSWORD_CHANGE,
}),
accountEditChanged: (user: Partial<Data.ServerInfo_User>) => ({
type: Types.ACCOUNT_EDIT_CHANGED,
user,
}),
accountImageChanged: (user: Partial<Data.ServerInfo_User>) => ({
type: Types.ACCOUNT_IMAGE_CHANGED,
user,
}),
getUserInfo: (userInfo: Data.ServerInfo_User) => ({
type: Types.GET_USER_INFO,
userInfo,
}),
notifyUser: (notification: Data.Event_NotifyUser) => ({
type: Types.NOTIFY_USER,
notification,
}),
serverShutdown: (data: Data.Event_ServerShutdown) => ({
type: Types.SERVER_SHUTDOWN,
data,
}),
userMessage: (messageData: Data.Event_UserMessage) => ({
type: Types.USER_MESSAGE,
messageData,
}),
addToList: (list: string, userName: string) => ({
type: Types.ADD_TO_LIST,
list,
userName,
}),
removeFromList: (list: string, userName: string) => ({
type: Types.REMOVE_FROM_LIST,
list,
userName,
}),
banFromServer: (userName: string) => ({
type: Types.BAN_FROM_SERVER,
userName,
}),
banHistory: (userName: string, banHistory: Data.ServerInfo_Ban[]) => ({
type: Types.BAN_HISTORY,
userName,
banHistory,
}),
warnHistory: (userName: string, warnHistory: Data.ServerInfo_Warning[]) => ({
type: Types.WARN_HISTORY,
userName,
warnHistory,
}),
warnListOptions: (warnList: Data.Response_WarnList[]) => ({
type: Types.WARN_LIST_OPTIONS,
warnList,
}),
warnUser: (userName: string) => ({
type: Types.WARN_USER,
userName,
}),
grantReplayAccess: (replayId: number, moderatorName: string) => ({
type: Types.GRANT_REPLAY_ACCESS,
replayId,
moderatorName,
}),
forceActivateUser: (usernameToActivate: string, moderatorName: string) => ({
type: Types.FORCE_ACTIVATE_USER,
usernameToActivate,
moderatorName,
}),
getAdminNotes: (userName: string, notes: string) => ({
type: Types.GET_ADMIN_NOTES,
userName,
notes,
}),
updateAdminNotes: (userName: string, notes: string) => ({
type: Types.UPDATE_ADMIN_NOTES,
userName,
notes,
}),
replayList: (matchList: Data.ServerInfo_ReplayMatch[]) => ({ type: Types.REPLAY_LIST, matchList }),
replayAdded: (matchInfo: Data.ServerInfo_ReplayMatch) => ({ type: Types.REPLAY_ADDED, matchInfo }),
replayModifyMatch: (gameId: number, doNotHide: boolean) => ({ type: Types.REPLAY_MODIFY_MATCH, gameId, doNotHide }),
replayDeleteMatch: (gameId: number) => ({ type: Types.REPLAY_DELETE_MATCH, gameId }),
backendDecks: (deckList: Data.Response_DeckList) => ({ type: Types.BACKEND_DECKS, deckList }),
deckNewDir: (path: string, dirName: string) => ({ type: Types.DECK_NEW_DIR, path, dirName }),
deckDelDir: (path: string) => ({ type: Types.DECK_DEL_DIR, path }),
deckUpload: (path: string, treeItem: Data.ServerInfo_DeckStorage_TreeItem) => ({ type: Types.DECK_UPLOAD, path, treeItem }),
deckDelete: (deckId: number) => ({ type: Types.DECK_DELETE, deckId }),
gamesOfUser: (userName: string, response: Data.Response_GetGamesOfUser) =>
({ type: Types.GAMES_OF_USER, userName, response }),
}
export const Actions = serverSlice.actions;
export type ServerAction = ReturnType<typeof Actions[keyof typeof Actions]>;

View file

@ -43,7 +43,7 @@ describe('Dispatch', () => {
it('loginSuccessful dispatches Actions.loginSuccessful()', () => {
const options = makeLoginSuccessContext();
Dispatch.loginSuccessful(options);
expect(mockDispatch).toHaveBeenCalledWith(Actions.loginSuccessful(options));
expect(mockDispatch).toHaveBeenCalledWith(Actions.loginSuccessful({ options }));
});
it('loginFailed dispatches Actions.loginFailed()', () => {
@ -69,74 +69,74 @@ describe('Dispatch', () => {
it('updateBuddyList dispatches Actions.updateBuddyList()', () => {
const list = [makeUser()];
Dispatch.updateBuddyList(list);
expect(mockDispatch).toHaveBeenCalledWith(Actions.updateBuddyList(list));
expect(mockDispatch).toHaveBeenCalledWith(Actions.updateBuddyList({ buddyList: list }));
});
it('addToBuddyList dispatches Actions.addToBuddyList()', () => {
const user = makeUser();
Dispatch.addToBuddyList(user);
expect(mockDispatch).toHaveBeenCalledWith(Actions.addToBuddyList(user));
expect(mockDispatch).toHaveBeenCalledWith(Actions.addToBuddyList({ user }));
});
it('removeFromBuddyList dispatches Actions.removeFromBuddyList()', () => {
Dispatch.removeFromBuddyList('Alice');
expect(mockDispatch).toHaveBeenCalledWith(Actions.removeFromBuddyList('Alice'));
expect(mockDispatch).toHaveBeenCalledWith(Actions.removeFromBuddyList({ userName: 'Alice' }));
});
it('updateIgnoreList dispatches Actions.updateIgnoreList()', () => {
const list = [makeUser()];
Dispatch.updateIgnoreList(list);
expect(mockDispatch).toHaveBeenCalledWith(Actions.updateIgnoreList(list));
expect(mockDispatch).toHaveBeenCalledWith(Actions.updateIgnoreList({ ignoreList: list }));
});
it('addToIgnoreList dispatches Actions.addToIgnoreList()', () => {
const user = makeUser();
Dispatch.addToIgnoreList(user);
expect(mockDispatch).toHaveBeenCalledWith(Actions.addToIgnoreList(user));
expect(mockDispatch).toHaveBeenCalledWith(Actions.addToIgnoreList({ user }));
});
it('removeFromIgnoreList dispatches Actions.removeFromIgnoreList()', () => {
Dispatch.removeFromIgnoreList('Bob');
expect(mockDispatch).toHaveBeenCalledWith(Actions.removeFromIgnoreList('Bob'));
expect(mockDispatch).toHaveBeenCalledWith(Actions.removeFromIgnoreList({ userName: 'Bob' }));
});
it('updateInfo dispatches Actions.updateInfo({ name, version })', () => {
it('updateInfo dispatches Actions.updateInfo({ info: { name, version } })', () => {
Dispatch.updateInfo('Servatrice', '2.9');
expect(mockDispatch).toHaveBeenCalledWith(Actions.updateInfo({ name: 'Servatrice', version: '2.9' }));
expect(mockDispatch).toHaveBeenCalledWith(Actions.updateInfo({ info: { name: 'Servatrice', version: '2.9' } }));
});
it('updateStatus dispatches Actions.updateStatus({ state, description })', () => {
it('updateStatus dispatches Actions.updateStatus({ status: { state, description } })', () => {
Dispatch.updateStatus(App.StatusEnum.CONNECTED, 'ok');
expect(mockDispatch).toHaveBeenCalledWith(Actions.updateStatus({ state: App.StatusEnum.CONNECTED, description: 'ok' }));
expect(mockDispatch).toHaveBeenCalledWith(Actions.updateStatus({ status: { state: App.StatusEnum.CONNECTED, description: 'ok' } }));
});
it('updateUser dispatches Actions.updateUser()', () => {
const user = makeUser();
Dispatch.updateUser(user);
expect(mockDispatch).toHaveBeenCalledWith(Actions.updateUser(user));
expect(mockDispatch).toHaveBeenCalledWith(Actions.updateUser({ user }));
});
it('updateUsers dispatches Actions.updateUsers()', () => {
const users = [makeUser()];
Dispatch.updateUsers(users);
expect(mockDispatch).toHaveBeenCalledWith(Actions.updateUsers(users));
expect(mockDispatch).toHaveBeenCalledWith(Actions.updateUsers({ users }));
});
it('userJoined dispatches Actions.userJoined()', () => {
const user = makeUser();
Dispatch.userJoined(user);
expect(mockDispatch).toHaveBeenCalledWith(Actions.userJoined(user));
expect(mockDispatch).toHaveBeenCalledWith(Actions.userJoined({ user }));
});
it('userLeft dispatches Actions.userLeft()', () => {
Dispatch.userLeft('Carol');
expect(mockDispatch).toHaveBeenCalledWith(Actions.userLeft('Carol'));
expect(mockDispatch).toHaveBeenCalledWith(Actions.userLeft({ name: 'Carol' }));
});
it('viewLogs dispatches Actions.viewLogs()', () => {
const logs = [create(Data.ServerInfo_ChatMessageSchema, { targetType: 'room' })];
Dispatch.viewLogs(logs);
expect(mockDispatch).toHaveBeenCalledWith(Actions.viewLogs(logs));
expect(mockDispatch).toHaveBeenCalledWith(Actions.viewLogs({ logs }));
});
it('clearLogs dispatches Actions.clearLogs()', () => {
@ -146,7 +146,7 @@ describe('Dispatch', () => {
it('serverMessage dispatches Actions.serverMessage()', () => {
Dispatch.serverMessage('Welcome!');
expect(mockDispatch).toHaveBeenCalledWith(Actions.serverMessage('Welcome!'));
expect(mockDispatch).toHaveBeenCalledWith(Actions.serverMessage({ message: 'Welcome!' }));
});
it('registrationRequiresEmail dispatches correctly', () => {
@ -161,33 +161,33 @@ describe('Dispatch', () => {
it('registrationFailed passes reason and endTime to action', () => {
Dispatch.registrationFailed('reason', 999);
expect(mockDispatch).toHaveBeenCalledWith(Actions.registrationFailed('reason', 999));
expect(mockDispatch).toHaveBeenCalledWith(Actions.registrationFailed({ reason: 'reason', endTime: 999 }));
});
it('registrationFailed passes reason only when no endTime', () => {
Dispatch.registrationFailed('plain reason');
expect(mockDispatch).toHaveBeenCalledWith(Actions.registrationFailed('plain reason', undefined));
expect(mockDispatch).toHaveBeenCalledWith(Actions.registrationFailed({ reason: 'plain reason', endTime: undefined }));
});
it('registrationEmailError dispatches correctly', () => {
Dispatch.registrationEmailError('bad');
expect(mockDispatch).toHaveBeenCalledWith(Actions.registrationEmailError('bad'));
expect(mockDispatch).toHaveBeenCalledWith(Actions.registrationEmailError({ error: 'bad' }));
});
it('registrationPasswordError dispatches correctly', () => {
Dispatch.registrationPasswordError('weak');
expect(mockDispatch).toHaveBeenCalledWith(Actions.registrationPasswordError('weak'));
expect(mockDispatch).toHaveBeenCalledWith(Actions.registrationPasswordError({ error: 'weak' }));
});
it('registrationUserNameError dispatches correctly', () => {
Dispatch.registrationUserNameError('taken');
expect(mockDispatch).toHaveBeenCalledWith(Actions.registrationUserNameError('taken'));
expect(mockDispatch).toHaveBeenCalledWith(Actions.registrationUserNameError({ error: 'taken' }));
});
it('accountAwaitingActivation dispatches correctly', () => {
const options = makePendingActivationContext();
Dispatch.accountAwaitingActivation(options);
expect(mockDispatch).toHaveBeenCalledWith(Actions.accountAwaitingActivation(options));
expect(mockDispatch).toHaveBeenCalledWith(Actions.accountAwaitingActivation({ options }));
});
it('accountActivationSuccess dispatches correctly', () => {
@ -222,7 +222,7 @@ describe('Dispatch', () => {
it('adjustMod dispatches Actions.adjustMod()', () => {
Dispatch.adjustMod('Dan', true, false);
expect(mockDispatch).toHaveBeenCalledWith(Actions.adjustMod('Dan', true, false));
expect(mockDispatch).toHaveBeenCalledWith(Actions.adjustMod({ userName: 'Dan', shouldBeMod: true, shouldBeJudge: false }));
});
it('reloadConfig dispatches correctly', () => {
@ -248,150 +248,150 @@ describe('Dispatch', () => {
it('accountEditChanged dispatches correctly', () => {
const user = makeUser();
Dispatch.accountEditChanged(user);
expect(mockDispatch).toHaveBeenCalledWith(Actions.accountEditChanged(user));
expect(mockDispatch).toHaveBeenCalledWith(Actions.accountEditChanged({ user }));
});
it('accountImageChanged dispatches correctly', () => {
const user = makeUser();
Dispatch.accountImageChanged(user);
expect(mockDispatch).toHaveBeenCalledWith(Actions.accountImageChanged(user));
expect(mockDispatch).toHaveBeenCalledWith(Actions.accountImageChanged({ user }));
});
it('getUserInfo dispatches correctly', () => {
const userInfo = makeUser({ name: 'Frank' });
Dispatch.getUserInfo(userInfo);
expect(mockDispatch).toHaveBeenCalledWith(Actions.getUserInfo(userInfo));
expect(mockDispatch).toHaveBeenCalledWith(Actions.getUserInfo({ userInfo }));
});
it('notifyUser dispatches correctly', () => {
const notification = create(Data.Event_NotifyUserSchema, { type: 1, warningReason: '', customTitle: '', customContent: '' });
Dispatch.notifyUser(notification);
expect(mockDispatch).toHaveBeenCalledWith(Actions.notifyUser(notification));
expect(mockDispatch).toHaveBeenCalledWith(Actions.notifyUser({ notification }));
});
it('serverShutdown dispatches correctly', () => {
const data = create(Data.Event_ServerShutdownSchema, { reason: 'maintenance', minutes: 5 });
Dispatch.serverShutdown(data);
expect(mockDispatch).toHaveBeenCalledWith(Actions.serverShutdown(data));
expect(mockDispatch).toHaveBeenCalledWith(Actions.serverShutdown({ data }));
});
it('userMessage dispatches correctly', () => {
const messageData = create(Data.Event_UserMessageSchema, { senderName: 'Alice', receiverName: 'Bob', message: 'hey' });
Dispatch.userMessage(messageData);
expect(mockDispatch).toHaveBeenCalledWith(Actions.userMessage(messageData));
expect(mockDispatch).toHaveBeenCalledWith(Actions.userMessage({ messageData }));
});
it('addToList dispatches correctly', () => {
Dispatch.addToList('buddyList', 'Grace');
expect(mockDispatch).toHaveBeenCalledWith(Actions.addToList('buddyList', 'Grace'));
expect(mockDispatch).toHaveBeenCalledWith(Actions.addToList({ list: 'buddyList', userName: 'Grace' }));
});
it('removeFromList dispatches correctly', () => {
Dispatch.removeFromList('buddyList', 'Hank');
expect(mockDispatch).toHaveBeenCalledWith(Actions.removeFromList('buddyList', 'Hank'));
expect(mockDispatch).toHaveBeenCalledWith(Actions.removeFromList({ list: 'buddyList', userName: 'Hank' }));
});
it('banFromServer dispatches correctly', () => {
Dispatch.banFromServer('Ira');
expect(mockDispatch).toHaveBeenCalledWith(Actions.banFromServer('Ira'));
expect(mockDispatch).toHaveBeenCalledWith(Actions.banFromServer({ userName: 'Ira' }));
});
it('banHistory dispatches correctly', () => {
const history = [makeBanHistoryItem()];
Dispatch.banHistory('Ira', history);
expect(mockDispatch).toHaveBeenCalledWith(Actions.banHistory('Ira', history));
expect(mockDispatch).toHaveBeenCalledWith(Actions.banHistory({ userName: 'Ira', banHistory: history }));
});
it('warnHistory dispatches correctly', () => {
const history = [makeWarnHistoryItem()];
Dispatch.warnHistory('Jack', history);
expect(mockDispatch).toHaveBeenCalledWith(Actions.warnHistory('Jack', history));
expect(mockDispatch).toHaveBeenCalledWith(Actions.warnHistory({ userName: 'Jack', warnHistory: history }));
});
it('warnListOptions dispatches correctly', () => {
const list = [makeWarnListItem()];
Dispatch.warnListOptions(list);
expect(mockDispatch).toHaveBeenCalledWith(Actions.warnListOptions(list));
expect(mockDispatch).toHaveBeenCalledWith(Actions.warnListOptions({ warnList: list }));
});
it('warnUser dispatches correctly', () => {
Dispatch.warnUser('Kelly');
expect(mockDispatch).toHaveBeenCalledWith(Actions.warnUser('Kelly'));
expect(mockDispatch).toHaveBeenCalledWith(Actions.warnUser({ userName: 'Kelly' }));
});
it('grantReplayAccess dispatches correctly', () => {
Dispatch.grantReplayAccess(7, 'Moe');
expect(mockDispatch).toHaveBeenCalledWith(Actions.grantReplayAccess(7, 'Moe'));
expect(mockDispatch).toHaveBeenCalledWith(Actions.grantReplayAccess({ replayId: 7, moderatorName: 'Moe' }));
});
it('forceActivateUser dispatches correctly', () => {
Dispatch.forceActivateUser('Ned', 'Moe');
expect(mockDispatch).toHaveBeenCalledWith(Actions.forceActivateUser('Ned', 'Moe'));
expect(mockDispatch).toHaveBeenCalledWith(Actions.forceActivateUser({ usernameToActivate: 'Ned', moderatorName: 'Moe' }));
});
it('getAdminNotes dispatches correctly', () => {
Dispatch.getAdminNotes('Ned', 'notes');
expect(mockDispatch).toHaveBeenCalledWith(Actions.getAdminNotes('Ned', 'notes'));
expect(mockDispatch).toHaveBeenCalledWith(Actions.getAdminNotes({ userName: 'Ned', notes: 'notes' }));
});
it('updateAdminNotes dispatches correctly', () => {
Dispatch.updateAdminNotes('Ned', 'updated');
expect(mockDispatch).toHaveBeenCalledWith(Actions.updateAdminNotes('Ned', 'updated'));
expect(mockDispatch).toHaveBeenCalledWith(Actions.updateAdminNotes({ userName: 'Ned', notes: 'updated' }));
});
it('replayList dispatches correctly', () => {
const list = [makeReplayMatch()];
Dispatch.replayList(list);
expect(mockDispatch).toHaveBeenCalledWith(Actions.replayList(list));
expect(mockDispatch).toHaveBeenCalledWith(Actions.replayList({ matchList: list }));
});
it('replayAdded dispatches correctly', () => {
const match = makeReplayMatch();
Dispatch.replayAdded(match);
expect(mockDispatch).toHaveBeenCalledWith(Actions.replayAdded(match));
expect(mockDispatch).toHaveBeenCalledWith(Actions.replayAdded({ matchInfo: match }));
});
it('replayModifyMatch dispatches correctly', () => {
Dispatch.replayModifyMatch(5, true);
expect(mockDispatch).toHaveBeenCalledWith(Actions.replayModifyMatch(5, true));
expect(mockDispatch).toHaveBeenCalledWith(Actions.replayModifyMatch({ gameId: 5, doNotHide: true }));
});
it('replayDeleteMatch dispatches correctly', () => {
Dispatch.replayDeleteMatch(5);
expect(mockDispatch).toHaveBeenCalledWith(Actions.replayDeleteMatch(5));
expect(mockDispatch).toHaveBeenCalledWith(Actions.replayDeleteMatch({ gameId: 5 }));
});
it('backendDecks dispatches correctly', () => {
const deckList = makeDeckList();
Dispatch.backendDecks(deckList);
expect(mockDispatch).toHaveBeenCalledWith(Actions.backendDecks(deckList));
expect(mockDispatch).toHaveBeenCalledWith(Actions.backendDecks({ deckList }));
});
it('deckNewDir dispatches correctly', () => {
Dispatch.deckNewDir('a/b', 'newFolder');
expect(mockDispatch).toHaveBeenCalledWith(Actions.deckNewDir('a/b', 'newFolder'));
expect(mockDispatch).toHaveBeenCalledWith(Actions.deckNewDir({ path: 'a/b', dirName: 'newFolder' }));
});
it('deckDelDir dispatches correctly', () => {
Dispatch.deckDelDir('a/b');
expect(mockDispatch).toHaveBeenCalledWith(Actions.deckDelDir('a/b'));
expect(mockDispatch).toHaveBeenCalledWith(Actions.deckDelDir({ path: 'a/b' }));
});
it('deckUpload dispatches correctly', () => {
const treeItem = makeDeckTreeItem();
Dispatch.deckUpload('a/b', treeItem);
expect(mockDispatch).toHaveBeenCalledWith(Actions.deckUpload('a/b', treeItem));
expect(mockDispatch).toHaveBeenCalledWith(Actions.deckUpload({ path: 'a/b', treeItem }));
});
it('deckDelete dispatches correctly', () => {
Dispatch.deckDelete(42);
expect(mockDispatch).toHaveBeenCalledWith(Actions.deckDelete(42));
expect(mockDispatch).toHaveBeenCalledWith(Actions.deckDelete({ deckId: 42 }));
});
it('gamesOfUser dispatches correctly', () => {
const response = create(Data.Response_GetGamesOfUserSchema, { roomList: [], gameList: [] });
Dispatch.gamesOfUser('alice', response);
expect(mockDispatch).toHaveBeenCalledWith(Actions.gamesOfUser('alice', response));
expect(mockDispatch).toHaveBeenCalledWith(Actions.gamesOfUser({ userName: 'alice', response }));
});
it('clearRegistrationErrors dispatches correctly', () => {

View file

@ -13,7 +13,7 @@ export const Dispatch = {
store.dispatch(Actions.connectionAttempted());
},
loginSuccessful: (options: Enriched.LoginSuccessContext) => {
store.dispatch(Actions.loginSuccessful(options));
store.dispatch(Actions.loginSuccessful({ options }));
},
loginFailed: () => {
store.dispatch(Actions.loginFailed());
@ -28,79 +28,73 @@ export const Dispatch = {
store.dispatch(Actions.testConnectionFailed());
},
updateBuddyList: (buddyList: Data.ServerInfo_User[]) => {
store.dispatch(Actions.updateBuddyList(buddyList));
store.dispatch(Actions.updateBuddyList({ buddyList }));
},
addToBuddyList: (user: Data.ServerInfo_User) => {
store.dispatch(Actions.addToBuddyList(user));
store.dispatch(Actions.addToBuddyList({ user }));
},
removeFromBuddyList: (userName: string) => {
store.dispatch(Actions.removeFromBuddyList(userName));
store.dispatch(Actions.removeFromBuddyList({ userName }));
},
updateIgnoreList: (ignoreList: Data.ServerInfo_User[]) => {
store.dispatch(Actions.updateIgnoreList(ignoreList));
store.dispatch(Actions.updateIgnoreList({ ignoreList }));
},
addToIgnoreList: (user: Data.ServerInfo_User) => {
store.dispatch(Actions.addToIgnoreList(user));
store.dispatch(Actions.addToIgnoreList({ user }));
},
removeFromIgnoreList: (userName: string) => {
store.dispatch(Actions.removeFromIgnoreList(userName));
store.dispatch(Actions.removeFromIgnoreList({ userName }));
},
updateInfo: (name: string, version: string) => {
store.dispatch(Actions.updateInfo({
name,
version
}));
store.dispatch(Actions.updateInfo({ info: { name, version } }));
},
updateStatus: (state: App.StatusEnum, description: string) => {
store.dispatch(Actions.updateStatus({
state,
description
}));
store.dispatch(Actions.updateStatus({ status: { state, description } }));
},
updateUser: (user: Data.ServerInfo_User) => {
store.dispatch(Actions.updateUser(user));
store.dispatch(Actions.updateUser({ user }));
},
updateUsers: (users: Data.ServerInfo_User[]) => {
store.dispatch(Actions.updateUsers(users));
store.dispatch(Actions.updateUsers({ users }));
},
userJoined: (user: Data.ServerInfo_User) => {
store.dispatch(Actions.userJoined(user));
store.dispatch(Actions.userJoined({ user }));
},
userLeft: (name: string) => {
store.dispatch(Actions.userLeft(name));
store.dispatch(Actions.userLeft({ name }));
},
viewLogs: (logs: Data.ServerInfo_ChatMessage[]) => {
store.dispatch(Actions.viewLogs(logs));
store.dispatch(Actions.viewLogs({ logs }));
},
clearLogs: () => {
store.dispatch(Actions.clearLogs());
},
serverMessage: (message: string) => {
store.dispatch(Actions.serverMessage(message));
store.dispatch(Actions.serverMessage({ message }));
},
registrationRequiresEmail: () => {
store.dispatch(Actions.registrationRequiresEmail());
},
registrationSuccess: () => {
store.dispatch(Actions.registrationSuccess())
store.dispatch(Actions.registrationSuccess());
},
registrationFailed: (reason: string, endTime?: number) => {
store.dispatch(Actions.registrationFailed(reason, endTime));
store.dispatch(Actions.registrationFailed({ reason, endTime }));
},
clearRegistrationErrors: () => {
store.dispatch(Actions.clearRegistrationErrors());
},
registrationEmailError: (error: string) => {
store.dispatch(Actions.registrationEmailError(error));
store.dispatch(Actions.registrationEmailError({ error }));
},
registrationPasswordError: (error: string) => {
store.dispatch(Actions.registrationPasswordError(error));
store.dispatch(Actions.registrationPasswordError({ error }));
},
registrationUserNameError: (error: string) => {
store.dispatch(Actions.registrationUserNameError(error));
store.dispatch(Actions.registrationUserNameError({ error }));
},
accountAwaitingActivation: (options: Enriched.PendingActivationContext) => {
store.dispatch(Actions.accountAwaitingActivation(options));
store.dispatch(Actions.accountAwaitingActivation({ options }));
},
accountActivationSuccess: () => {
store.dispatch(Actions.accountActivationSuccess());
@ -121,7 +115,7 @@ export const Dispatch = {
store.dispatch(Actions.resetPasswordSuccess());
},
adjustMod: (userName: string, shouldBeMod: boolean, shouldBeJudge: boolean) => {
store.dispatch(Actions.adjustMod(userName, shouldBeMod, shouldBeJudge));
store.dispatch(Actions.adjustMod({ userName, shouldBeMod, shouldBeJudge }));
},
reloadConfig: () => {
store.dispatch(Actions.reloadConfig());
@ -136,84 +130,84 @@ export const Dispatch = {
store.dispatch(Actions.accountPasswordChange());
},
accountEditChanged: (user: Partial<Data.ServerInfo_User>) => {
store.dispatch(Actions.accountEditChanged(user));
store.dispatch(Actions.accountEditChanged({ user }));
},
accountImageChanged: (user: Partial<Data.ServerInfo_User>) => {
store.dispatch(Actions.accountImageChanged(user));
store.dispatch(Actions.accountImageChanged({ user }));
},
getUserInfo: (userInfo: Data.ServerInfo_User) => {
store.dispatch(Actions.getUserInfo(userInfo));
store.dispatch(Actions.getUserInfo({ userInfo }));
},
notifyUser: (notification: Data.Event_NotifyUser) => {
store.dispatch(Actions.notifyUser(notification))
store.dispatch(Actions.notifyUser({ notification }));
},
serverShutdown: (data: Data.Event_ServerShutdown) => {
store.dispatch(Actions.serverShutdown(data))
store.dispatch(Actions.serverShutdown({ data }));
},
userMessage: (messageData: Data.Event_UserMessage) => {
store.dispatch(Actions.userMessage(messageData))
store.dispatch(Actions.userMessage({ messageData }));
},
addToList: (list: string, userName: string) => {
store.dispatch(Actions.addToList(list, userName))
store.dispatch(Actions.addToList({ list, userName }));
},
removeFromList: (list: string, userName: string) => {
store.dispatch(Actions.removeFromList(list, userName))
store.dispatch(Actions.removeFromList({ list, userName }));
},
banFromServer: (userName: string) => {
store.dispatch(Actions.banFromServer(userName));
store.dispatch(Actions.banFromServer({ userName }));
},
banHistory: (userName: string, banHistory: Data.ServerInfo_Ban[]) => {
store.dispatch(Actions.banHistory(userName, banHistory))
store.dispatch(Actions.banHistory({ userName, banHistory }));
},
warnHistory: (userName: string, warnHistory: Data.ServerInfo_Warning[]) => {
store.dispatch(Actions.warnHistory(userName, warnHistory))
store.dispatch(Actions.warnHistory({ userName, warnHistory }));
},
warnListOptions: (warnList: Data.Response_WarnList[]) => {
store.dispatch(Actions.warnListOptions(warnList))
store.dispatch(Actions.warnListOptions({ warnList }));
},
warnUser: (userName: string) => {
store.dispatch(Actions.warnUser(userName))
store.dispatch(Actions.warnUser({ userName }));
},
grantReplayAccess: (replayId: number, moderatorName: string) => {
store.dispatch(Actions.grantReplayAccess(replayId, moderatorName));
store.dispatch(Actions.grantReplayAccess({ replayId, moderatorName }));
},
forceActivateUser: (usernameToActivate: string, moderatorName: string) => {
store.dispatch(Actions.forceActivateUser(usernameToActivate, moderatorName));
store.dispatch(Actions.forceActivateUser({ usernameToActivate, moderatorName }));
},
getAdminNotes: (userName: string, notes: string) => {
store.dispatch(Actions.getAdminNotes(userName, notes));
store.dispatch(Actions.getAdminNotes({ userName, notes }));
},
updateAdminNotes: (userName: string, notes: string) => {
store.dispatch(Actions.updateAdminNotes(userName, notes));
store.dispatch(Actions.updateAdminNotes({ userName, notes }));
},
replayList: (matchList: Data.ServerInfo_ReplayMatch[]) => {
store.dispatch(Actions.replayList(matchList));
store.dispatch(Actions.replayList({ matchList }));
},
replayAdded: (matchInfo: Data.ServerInfo_ReplayMatch) => {
store.dispatch(Actions.replayAdded(matchInfo));
store.dispatch(Actions.replayAdded({ matchInfo }));
},
replayModifyMatch: (gameId: number, doNotHide: boolean) => {
store.dispatch(Actions.replayModifyMatch(gameId, doNotHide));
store.dispatch(Actions.replayModifyMatch({ gameId, doNotHide }));
},
replayDeleteMatch: (gameId: number) => {
store.dispatch(Actions.replayDeleteMatch(gameId));
store.dispatch(Actions.replayDeleteMatch({ gameId }));
},
backendDecks: (deckList: Data.Response_DeckList) => {
store.dispatch(Actions.backendDecks(deckList));
store.dispatch(Actions.backendDecks({ deckList }));
},
deckNewDir: (path: string, dirName: string) => {
store.dispatch(Actions.deckNewDir(path, dirName));
store.dispatch(Actions.deckNewDir({ path, dirName }));
},
deckDelDir: (path: string) => {
store.dispatch(Actions.deckDelDir(path));
store.dispatch(Actions.deckDelDir({ path }));
},
deckUpload: (path: string, treeItem: Data.ServerInfo_DeckStorage_TreeItem) => {
store.dispatch(Actions.deckUpload(path, treeItem));
store.dispatch(Actions.deckUpload({ path, treeItem }));
},
deckDelete: (deckId: number) => {
store.dispatch(Actions.deckDelete(deckId));
store.dispatch(Actions.deckDelete({ deckId }));
},
gamesOfUser: (userName: string, response: Data.Response_GetGamesOfUser) => {
store.dispatch(Actions.gamesOfUser(userName, response));
store.dispatch(Actions.gamesOfUser({ userName, response }));
},
}
};

View file

@ -2,13 +2,16 @@ import { App, Data, Enriched } from '@app/types';
export interface ServerState {
initialized: boolean;
buddyList: Data.ServerInfo_User[];
ignoreList: Data.ServerInfo_User[];
/** Buddies keyed by username for O(1) lookup. Use `getSortedBuddyList` for display. */
buddyList: { [userName: string]: Data.ServerInfo_User };
/** Ignored users keyed by username for O(1) lookup. Use `getSortedIgnoreList` for display. */
ignoreList: { [userName: string]: Data.ServerInfo_User };
info: ServerStateInfo;
status: ServerStateStatus;
logs: ServerStateLogs;
user: Data.ServerInfo_User | null;
users: Data.ServerInfo_User[];
/** Connected users keyed by username for O(1) lookup. Use `getSortedUsers` for display. */
users: { [userName: string]: Data.ServerInfo_User };
sortUsersBy: ServerStateSortUsersBy;
messages: {
[userName: string]: Data.Event_UserMessage[];
@ -28,7 +31,8 @@ export interface ServerState {
warnListOptions: Data.Response_WarnList[];
warnUser: string;
adminNotes: { [userName: string]: string };
replays: Data.ServerInfo_ReplayMatch[];
/** Replays keyed by gameId for O(1) lookup/update. */
replays: { [gameId: number]: Data.ServerInfo_ReplayMatch };
backendDecks: Data.Response_DeckList | null;
gamesOfUser: { [userName: string]: Enriched.Game[] };
registrationError: string | null;

View file

@ -1,7 +1,7 @@
import { App, Data } from '@app/types';
import { create } from '@bufbuild/protobuf';
import { serverReducer } from './server.reducer';
import { Types } from './server.types';
import { Actions } from './server.actions';
import {
makeBanHistoryItem,
makePendingActivationContext,
@ -24,22 +24,22 @@ describe('Initialisation', () => {
it('returns initialState when called with undefined state', () => {
const result = serverReducer(undefined, { type: '@@INIT' });
expect(result.initialized).toBe(false);
expect(result.buddyList).toEqual([]);
expect(result.buddyList).toEqual({});
expect(result.status.state).toBe(App.StatusEnum.DISCONNECTED);
});
it('INITIALIZED → resets to initialState with initialized: true', () => {
const state = makeServerState({ banUser: 'someone', initialized: false });
const result = serverReducer(state, { type: Types.INITIALIZED });
const result = serverReducer(state, Actions.initialized());
expect(result.initialized).toBe(true);
expect(result.banUser).toBe('');
expect(result.buddyList).toEqual([]);
expect(result.buddyList).toEqual({});
});
it('CLEAR_STORE → resets to initialState but preserves status', () => {
const status = { state: App.StatusEnum.LOGGED_IN, description: 'logged in', connectionAttemptMade: true };
const state = makeServerState({ status, banUser: 'someone' });
const result = serverReducer(state, { type: Types.CLEAR_STORE });
const result = serverReducer(state, Actions.clearStore());
expect(result.banUser).toBe('');
expect(result.status).toEqual(status);
expect(result.initialized).toBe(false);
@ -48,7 +48,7 @@ describe('Initialisation', () => {
it('default → returns state unchanged for unknown action', () => {
const state = makeServerState();
const result = serverReducer(state, { type: '@@UNKNOWN' });
expect(result).toBe(state);
expect(result).toEqual(state);
});
});
@ -57,27 +57,27 @@ describe('Initialisation', () => {
describe('Account & Connection', () => {
it('CONNECTION_ATTEMPTED → sets connectionAttemptMade to true', () => {
const state = makeServerState({ status: { connectionAttemptMade: false, state: App.StatusEnum.DISCONNECTED, description: null } });
const result = serverReducer(state, { type: Types.CONNECTION_ATTEMPTED });
const result = serverReducer(state, Actions.connectionAttempted());
expect(result.status.connectionAttemptMade).toBe(true);
});
it('ACCOUNT_AWAITING_ACTIVATION → returns state unchanged', () => {
const options = makePendingActivationContext();
const state = makeServerState();
const result = serverReducer(state, { type: Types.ACCOUNT_AWAITING_ACTIVATION, options });
expect(result).toBe(state);
const result = serverReducer(state, Actions.accountAwaitingActivation({ options }));
expect(result).toEqual(state);
});
it('ACCOUNT_ACTIVATION_SUCCESS → returns state unchanged', () => {
const state = makeServerState();
const result = serverReducer(state, { type: Types.ACCOUNT_ACTIVATION_SUCCESS });
expect(result).toBe(state);
const result = serverReducer(state, Actions.accountActivationSuccess());
expect(result).toEqual(state);
});
it('ACCOUNT_ACTIVATION_FAILED → returns state unchanged', () => {
const state = makeServerState();
const result = serverReducer(state, { type: Types.ACCOUNT_ACTIVATION_FAILED });
expect(result).toBe(state);
const result = serverReducer(state, Actions.accountActivationFailed());
expect(result).toEqual(state);
});
});
@ -86,26 +86,26 @@ describe('Account & Connection', () => {
describe('Registration', () => {
it('REGISTRATION_FAILED → stores normalized error (plain reason)', () => {
const state = makeServerState({ registrationError: null });
const result = serverReducer(state, { type: Types.REGISTRATION_FAILED, reason: 'Server is disabled', endTime: undefined });
const result = serverReducer(state, Actions.registrationFailed({ reason: 'Server is disabled', endTime: undefined }));
expect(result.registrationError).toBe('Server is disabled');
});
it('REGISTRATION_FAILED → normalizes banned error when endTime is given', () => {
const state = makeServerState({ registrationError: null });
const result = serverReducer(state, { type: Types.REGISTRATION_FAILED, reason: 'bad actor', endTime: Date.now() + 100_000 });
const result = serverReducer(state, Actions.registrationFailed({ reason: 'bad actor', endTime: Date.now() + 100_000 }));
expect(result.registrationError).toContain('banned');
expect(result.registrationError).toContain('bad actor');
});
it('CLEAR_REGISTRATION_ERRORS → sets registrationError to null', () => {
const state = makeServerState({ registrationError: 'some error' });
const result = serverReducer(state, { type: Types.CLEAR_REGISTRATION_ERRORS });
const result = serverReducer(state, Actions.clearRegistrationErrors());
expect(result.registrationError).toBeNull();
});
it('CLEAR_STORE → resets registrationError to null', () => {
const state = makeServerState({ registrationError: 'stale error' });
const result = serverReducer(state, { type: Types.CLEAR_STORE });
const result = serverReducer(state, Actions.clearStore());
expect(result.registrationError).toBeNull();
});
});
@ -115,7 +115,7 @@ describe('Registration', () => {
describe('Server Info & Status', () => {
it('SERVER_MESSAGE → merges message into state.info', () => {
const state = makeServerState({ info: { message: null, name: 'Old', version: '1.0' } });
const result = serverReducer(state, { type: Types.SERVER_MESSAGE, message: 'Welcome!' });
const result = serverReducer(state, Actions.serverMessage({ message: 'Welcome!' }));
expect(result.info.message).toBe('Welcome!');
expect(result.info.name).toBe('Old');
expect(result.info.version).toBe('1.0');
@ -123,10 +123,7 @@ describe('Server Info & Status', () => {
it('UPDATE_INFO → merges name and version into state.info (not message)', () => {
const state = makeServerState({ info: { message: 'hi', name: null, version: null } });
const result = serverReducer(state, {
type: Types.UPDATE_INFO,
info: { name: 'Servatrice', version: '2.9.0' },
});
const result = serverReducer(state, Actions.updateInfo({ info: { name: 'Servatrice', version: '2.9.0' } }));
expect(result.info.name).toBe('Servatrice');
expect(result.info.version).toBe('2.9.0');
expect(result.info.message).toBe('hi');
@ -135,7 +132,7 @@ describe('Server Info & Status', () => {
it('UPDATE_STATUS → merges state and description into status', () => {
const state = makeServerState();
const update = { state: App.StatusEnum.LOGGED_IN, description: 'ok' };
const result = serverReducer(state, { type: Types.UPDATE_STATUS, status: update });
const result = serverReducer(state, Actions.updateStatus({ status: update }));
expect(result.status.state).toBe(App.StatusEnum.LOGGED_IN);
expect(result.status.description).toBe('ok');
expect(result.status.connectionAttemptMade).toBe(false);
@ -145,26 +142,23 @@ describe('Server Info & Status', () => {
// ── User ──────────────────────────────────────────────────────────────────────
describe('User', () => {
it('UPDATE_USER → merges action.user into state.user', () => {
it('UPDATE_USER → merges action.payload.user into state.user', () => {
const state = makeServerState({ user: makeUser({ name: 'Alice', userLevel: 1 }) });
const result = serverReducer(state, {
type: Types.UPDATE_USER,
user: { userLevel: 8 },
});
const result = serverReducer(state, Actions.updateUser({ user: { userLevel: 8 } as any }));
expect(result.user.name).toBe('Alice');
expect(result.user.userLevel).toBe(8);
});
it('ACCOUNT_EDIT_CHANGED → merges action.user into state.user', () => {
it('ACCOUNT_EDIT_CHANGED → merges action.payload.user into state.user', () => {
const state = makeServerState({ user: makeUser({ name: 'Alice' }) });
const result = serverReducer(state, { type: Types.ACCOUNT_EDIT_CHANGED, user: { realName: 'Alice Smith' } });
const result = serverReducer(state, Actions.accountEditChanged({ user: { realName: 'Alice Smith' } }));
expect(result.user.realName).toBe('Alice Smith');
expect(result.user.name).toBe('Alice');
});
it('ACCOUNT_IMAGE_CHANGED → merges action.user into state.user', () => {
it('ACCOUNT_IMAGE_CHANGED → merges action.payload.user into state.user', () => {
const state = makeServerState({ user: makeUser({ name: 'Alice' }) });
const result = serverReducer(state, { type: Types.ACCOUNT_IMAGE_CHANGED, user: { country: 'US' } });
const result = serverReducer(state, Actions.accountImageChanged({ user: { country: 'US' } }));
expect(result.user.country).toBe('US');
});
});
@ -172,74 +166,83 @@ describe('User', () => {
// ── Users List ────────────────────────────────────────────────────────────────
describe('Users List', () => {
it('UPDATE_USERS → replaces users list and sorts by name ASC', () => {
it('UPDATE_USERS → replaces users map keyed by name', () => {
const state = makeServerState();
const users = [makeUser({ name: 'Zane' }), makeUser({ name: 'Alice' })];
const result = serverReducer(state, { type: Types.UPDATE_USERS, users });
expect(result.users[0].name).toBe('Alice');
expect(result.users[1].name).toBe('Zane');
const result = serverReducer(state, Actions.updateUsers({ users }));
expect(result.users['Alice']).toBeDefined();
expect(result.users['Zane']).toBeDefined();
expect(Object.keys(result.users)).toHaveLength(2);
});
it('USER_JOINED → appends user and sorts', () => {
const state = makeServerState({ users: [makeUser({ name: 'Zane' })] });
const result = serverReducer(state, { type: Types.USER_JOINED, user: makeUser({ name: 'Alice' }) });
expect(result.users[0].name).toBe('Alice');
expect(result.users[1].name).toBe('Zane');
it('USER_JOINED → inserts user into map', () => {
const state = makeServerState({ users: { Zane: makeUser({ name: 'Zane' }) } });
const result = serverReducer(state, Actions.userJoined({ user: makeUser({ name: 'Alice' }) }));
expect(result.users['Alice']).toBeDefined();
expect(result.users['Zane']).toBeDefined();
});
it('USER_LEFT → removes user by name', () => {
const state = makeServerState({ users: [makeUser({ name: 'Alice' }), makeUser({ name: 'Bob' })] });
const result = serverReducer(state, { type: Types.USER_LEFT, name: 'Alice' });
expect(result.users).toHaveLength(1);
expect(result.users[0].name).toBe('Bob');
it('USER_LEFT → removes user by name from map', () => {
const state = makeServerState({
users: { Alice: makeUser({ name: 'Alice' }), Bob: makeUser({ name: 'Bob' }) },
});
const result = serverReducer(state, Actions.userLeft({ name: 'Alice' }));
expect(result.users['Alice']).toBeUndefined();
expect(result.users['Bob']).toBeDefined();
});
});
// ── Buddy & Ignore Lists ──────────────────────────────────────────────────────
describe('Buddy List', () => {
it('UPDATE_BUDDY_LIST → replaces and sorts buddy list', () => {
it('UPDATE_BUDDY_LIST → replaces map keyed by name', () => {
const state = makeServerState();
const buddyList = [makeUser({ name: 'Zane' }), makeUser({ name: 'Alice' })];
const result = serverReducer(state, { type: Types.UPDATE_BUDDY_LIST, buddyList });
expect(result.buddyList[0].name).toBe('Alice');
const result = serverReducer(state, Actions.updateBuddyList({ buddyList }));
expect(result.buddyList['Alice']).toBeDefined();
expect(result.buddyList['Zane']).toBeDefined();
});
it('ADD_TO_BUDDY_LIST → appends user and sorts', () => {
const state = makeServerState({ buddyList: [makeUser({ name: 'Zane' })] });
const result = serverReducer(state, { type: Types.ADD_TO_BUDDY_LIST, user: makeUser({ name: 'Alice' }) });
expect(result.buddyList[0].name).toBe('Alice');
expect(result.buddyList).toHaveLength(2);
it('ADD_TO_BUDDY_LIST → inserts user into map', () => {
const state = makeServerState({ buddyList: { Zane: makeUser({ name: 'Zane' }) } });
const result = serverReducer(state, Actions.addToBuddyList({ user: makeUser({ name: 'Alice' }) }));
expect(result.buddyList['Alice']).toBeDefined();
expect(Object.keys(result.buddyList)).toHaveLength(2);
});
it('REMOVE_FROM_BUDDY_LIST → removes user by name', () => {
const state = makeServerState({ buddyList: [makeUser({ name: 'Alice' }), makeUser({ name: 'Bob' })] });
const result = serverReducer(state, { type: Types.REMOVE_FROM_BUDDY_LIST, userName: 'Alice' });
expect(result.buddyList).toHaveLength(1);
expect(result.buddyList[0].name).toBe('Bob');
it('REMOVE_FROM_BUDDY_LIST → removes user by name from map', () => {
const state = makeServerState({
buddyList: { Alice: makeUser({ name: 'Alice' }), Bob: makeUser({ name: 'Bob' }) },
});
const result = serverReducer(state, Actions.removeFromBuddyList({ userName: 'Alice' }));
expect(result.buddyList['Alice']).toBeUndefined();
expect(result.buddyList['Bob']).toBeDefined();
});
});
describe('Ignore List', () => {
it('UPDATE_IGNORE_LIST → replaces and sorts ignore list', () => {
it('UPDATE_IGNORE_LIST → replaces map keyed by name', () => {
const state = makeServerState();
const ignoreList = [makeUser({ name: 'Zane' }), makeUser({ name: 'Alice' })];
const result = serverReducer(state, { type: Types.UPDATE_IGNORE_LIST, ignoreList });
expect(result.ignoreList[0].name).toBe('Alice');
const result = serverReducer(state, Actions.updateIgnoreList({ ignoreList }));
expect(result.ignoreList['Alice']).toBeDefined();
expect(result.ignoreList['Zane']).toBeDefined();
});
it('ADD_TO_IGNORE_LIST → appends user and sorts', () => {
const state = makeServerState({ ignoreList: [makeUser({ name: 'Zane' })] });
const result = serverReducer(state, { type: Types.ADD_TO_IGNORE_LIST, user: makeUser({ name: 'Alice' }) });
expect(result.ignoreList[0].name).toBe('Alice');
expect(result.ignoreList).toHaveLength(2);
it('ADD_TO_IGNORE_LIST → inserts user into map', () => {
const state = makeServerState({ ignoreList: { Zane: makeUser({ name: 'Zane' }) } });
const result = serverReducer(state, Actions.addToIgnoreList({ user: makeUser({ name: 'Alice' }) }));
expect(result.ignoreList['Alice']).toBeDefined();
expect(Object.keys(result.ignoreList)).toHaveLength(2);
});
it('REMOVE_FROM_IGNORE_LIST → removes user by name', () => {
const state = makeServerState({ ignoreList: [makeUser({ name: 'Alice' }), makeUser({ name: 'Bob' })] });
const result = serverReducer(state, { type: Types.REMOVE_FROM_IGNORE_LIST, userName: 'Alice' });
expect(result.ignoreList).toHaveLength(1);
expect(result.ignoreList[0].name).toBe('Bob');
it('REMOVE_FROM_IGNORE_LIST → removes user by name from map', () => {
const state = makeServerState({
ignoreList: { Alice: makeUser({ name: 'Alice' }), Bob: makeUser({ name: 'Bob' }) },
});
const result = serverReducer(state, Actions.removeFromIgnoreList({ userName: 'Alice' }));
expect(result.ignoreList['Alice']).toBeUndefined();
expect(result.ignoreList['Bob']).toBeDefined();
});
});
@ -249,13 +252,13 @@ describe('Logs', () => {
it('VIEW_LOGS → groups LogItem[] into room/game/chat buckets', () => {
const log = makeLogItem({ targetType: 'room' });
const state = makeServerState();
const result = serverReducer(state, { type: Types.VIEW_LOGS, logs: [log] });
const result = serverReducer(state, Actions.viewLogs({ logs: [log] }));
expect(result.logs.room).toEqual([log]);
});
it('CLEAR_LOGS → resets logs to empty arrays', () => {
const state = makeServerState({ logs: { room: [makeLogItem()], game: [], chat: [] } });
const result = serverReducer(state, { type: Types.CLEAR_LOGS });
const result = serverReducer(state, Actions.clearLogs());
expect(result.logs.room).toEqual([]);
expect(result.logs.game).toEqual([]);
expect(result.logs.chat).toEqual([]);
@ -267,18 +270,18 @@ describe('Logs', () => {
describe('Messaging', () => {
it('USER_MESSAGE → uses receiverName as key when current user is sender', () => {
const state = makeServerState({ user: makeUser({ name: 'Alice' }), messages: {} });
const messageData = { senderName: 'Alice', receiverName: 'Bob', message: 'hi' };
const result = serverReducer(state, { type: Types.USER_MESSAGE, messageData });
const messageData = { senderName: 'Alice', receiverName: 'Bob', message: 'hi' } as Data.Event_UserMessage;
const result = serverReducer(state, Actions.userMessage({ messageData }));
expect(result.messages['Bob']).toHaveLength(1);
expect(result.messages['Bob'][0]).toBe(messageData);
expect(result.messages['Bob'][0]).toEqual(messageData);
});
it('USER_MESSAGE → uses senderName as key when current user is receiver', () => {
const state = makeServerState({ user: makeUser({ name: 'Bob' }), messages: {} });
const messageData = { senderName: 'Alice', receiverName: 'Bob', message: 'yo' };
const result = serverReducer(state, { type: Types.USER_MESSAGE, messageData });
const messageData = { senderName: 'Alice', receiverName: 'Bob', message: 'yo' } as Data.Event_UserMessage;
const result = serverReducer(state, Actions.userMessage({ messageData }));
expect(result.messages['Alice']).toHaveLength(1);
expect(result.messages['Alice'][0]).toBe(messageData);
expect(result.messages['Alice'][0]).toEqual(messageData);
});
it('USER_MESSAGE → appends to existing messages for that user', () => {
@ -288,7 +291,7 @@ describe('Messaging', () => {
messages: { Alice: [existingMsg] },
});
const newMsg = create(Data.Event_UserMessageSchema, { senderName: 'Alice', receiverName: 'Bob', message: 'second' });
const result = serverReducer(state, { type: Types.USER_MESSAGE, messageData: newMsg });
const result = serverReducer(state, Actions.userMessage({ messageData: newMsg }));
expect(result.messages['Alice']).toHaveLength(2);
});
});
@ -299,23 +302,23 @@ describe('User Info & Notifications', () => {
it('GET_USER_INFO → adds userInfo keyed by name', () => {
const userInfo = makeUser({ name: 'Eve' });
const state = makeServerState();
const result = serverReducer(state, { type: Types.GET_USER_INFO, userInfo });
expect(result.userInfo['Eve']).toBe(userInfo);
const result = serverReducer(state, Actions.getUserInfo({ userInfo }));
expect(result.userInfo['Eve']).toEqual(userInfo);
});
it('NOTIFY_USER → appends notification to list', () => {
const state = makeServerState({ notifications: [] });
const notification = { type: 1, warningReason: '', customTitle: '', customContent: '' };
const result = serverReducer(state, { type: Types.NOTIFY_USER, notification });
const notification = { type: 1, warningReason: '', customTitle: '', customContent: '' } as unknown as Data.Event_NotifyUser;
const result = serverReducer(state, Actions.notifyUser({ notification }));
expect(result.notifications).toHaveLength(1);
expect(result.notifications[0]).toBe(notification);
expect(result.notifications[0]).toEqual(notification);
});
it('SERVER_SHUTDOWN → sets serverShutdown to action.data', () => {
const data = { reason: 'maintenance', minutes: 10 };
it('SERVER_SHUTDOWN → sets serverShutdown to action.payload.data', () => {
const data = { reason: 'maintenance', minutes: 10 } as unknown as Data.Event_ServerShutdown;
const state = makeServerState();
const result = serverReducer(state, { type: Types.SERVER_SHUTDOWN, data });
expect(result.serverShutdown).toBe(data);
const result = serverReducer(state, Actions.serverShutdown({ data }));
expect(result.serverShutdown).toEqual(data);
});
});
@ -324,46 +327,46 @@ describe('User Info & Notifications', () => {
describe('Moderation', () => {
it('BAN_FROM_SERVER → sets banUser', () => {
const state = makeServerState();
const result = serverReducer(state, { type: Types.BAN_FROM_SERVER, userName: 'Frank' });
const result = serverReducer(state, Actions.banFromServer({ userName: 'Frank' }));
expect(result.banUser).toBe('Frank');
});
it('BAN_HISTORY → adds banHistory keyed by userName', () => {
const history = [makeBanHistoryItem()];
const state = makeServerState();
const result = serverReducer(state, { type: Types.BAN_HISTORY, userName: 'Frank', banHistory: history });
expect(result.banHistory['Frank']).toBe(history);
const result = serverReducer(state, Actions.banHistory({ userName: 'Frank', banHistory: history }));
expect(result.banHistory['Frank']).toEqual(history);
});
it('WARN_HISTORY → adds warnHistory keyed by userName', () => {
const history = [makeWarnHistoryItem()];
const state = makeServerState();
const result = serverReducer(state, { type: Types.WARN_HISTORY, userName: 'Grace', warnHistory: history });
expect(result.warnHistory['Grace']).toBe(history);
const result = serverReducer(state, Actions.warnHistory({ userName: 'Grace', warnHistory: history }));
expect(result.warnHistory['Grace']).toEqual(history);
});
it('WARN_LIST_OPTIONS → replaces warnListOptions', () => {
const list = [makeWarnListItem()];
const state = makeServerState();
const result = serverReducer(state, { type: Types.WARN_LIST_OPTIONS, warnList: list });
expect(result.warnListOptions).toBe(list);
const result = serverReducer(state, Actions.warnListOptions({ warnList: list }));
expect(result.warnListOptions).toEqual(list);
});
it('WARN_USER → sets warnUser', () => {
const state = makeServerState();
const result = serverReducer(state, { type: Types.WARN_USER, userName: 'Hank' });
const result = serverReducer(state, Actions.warnUser({ userName: 'Hank' }));
expect(result.warnUser).toBe('Hank');
});
it('GET_ADMIN_NOTES → adds adminNotes keyed by userName', () => {
const state = makeServerState();
const result = serverReducer(state, { type: Types.GET_ADMIN_NOTES, userName: 'Ira', notes: 'note1' });
const result = serverReducer(state, Actions.getAdminNotes({ userName: 'Ira', notes: 'note1' }));
expect(result.adminNotes['Ira']).toBe('note1');
});
it('UPDATE_ADMIN_NOTES → updates adminNotes keyed by userName', () => {
const state = makeServerState({ adminNotes: { Ira: 'old' } });
const result = serverReducer(state, { type: Types.UPDATE_ADMIN_NOTES, userName: 'Ira', notes: 'new' });
const result = serverReducer(state, Actions.updateAdminNotes({ userName: 'Ira', notes: 'new' }));
expect(result.adminNotes['Ira']).toBe('new');
});
});
@ -374,87 +377,108 @@ describe('ADJUST_MOD', () => {
const baseUserLevel = UserLevelFlag.IsUser | UserLevelFlag.IsRegistered | UserLevelFlag.IsModerator | UserLevelFlag.IsJudge;
it('shouldBeMod=true, shouldBeJudge=true → sets both bits, preserves IsUser|IsRegistered', () => {
const state = makeServerState({ users: [makeUser({ name: 'Dan', userLevel: baseUserLevel })] });
const result = serverReducer(state, { type: Types.ADJUST_MOD, userName: 'Dan', shouldBeMod: true, shouldBeJudge: true });
const state = makeServerState({ users: { Dan: makeUser({ name: 'Dan', userLevel: baseUserLevel }) } });
const result = serverReducer(state, Actions.adjustMod({ userName: 'Dan', shouldBeMod: true, shouldBeJudge: true }));
// IsUser(1) | IsRegistered(2) | IsModerator(4) | IsJudge(16) = 23
expect(result.users[0].userLevel).toBe(23);
expect(result.users['Dan'].userLevel).toBe(23);
});
it('shouldBeMod=true, shouldBeJudge=false → sets IsModerator, clears IsJudge, preserves others', () => {
const state = makeServerState({ users: [makeUser({ name: 'Dan', userLevel: baseUserLevel })] });
const result = serverReducer(state, { type: Types.ADJUST_MOD, userName: 'Dan', shouldBeMod: true, shouldBeJudge: false });
const state = makeServerState({ users: { Dan: makeUser({ name: 'Dan', userLevel: baseUserLevel }) } });
const result = serverReducer(state, Actions.adjustMod({ userName: 'Dan', shouldBeMod: true, shouldBeJudge: false }));
// IsUser(1) | IsRegistered(2) | IsModerator(4) = 7
expect(result.users[0].userLevel).toBe(7);
expect(result.users['Dan'].userLevel).toBe(7);
});
it('shouldBeMod=false, shouldBeJudge=true → clears IsModerator, sets IsJudge, preserves others', () => {
const state = makeServerState({ users: [makeUser({ name: 'Dan', userLevel: baseUserLevel })] });
const result = serverReducer(state, { type: Types.ADJUST_MOD, userName: 'Dan', shouldBeMod: false, shouldBeJudge: true });
const state = makeServerState({ users: { Dan: makeUser({ name: 'Dan', userLevel: baseUserLevel }) } });
const result = serverReducer(state, Actions.adjustMod({ userName: 'Dan', shouldBeMod: false, shouldBeJudge: true }));
// IsUser(1) | IsRegistered(2) | IsJudge(16) = 19
expect(result.users[0].userLevel).toBe(19);
expect(result.users['Dan'].userLevel).toBe(19);
});
it('shouldBeMod=false, shouldBeJudge=false → clears both bits, preserves IsUser|IsRegistered', () => {
const state = makeServerState({ users: [makeUser({ name: 'Dan', userLevel: baseUserLevel })] });
const result = serverReducer(state, { type: Types.ADJUST_MOD, userName: 'Dan', shouldBeMod: false, shouldBeJudge: false });
const state = makeServerState({ users: { Dan: makeUser({ name: 'Dan', userLevel: baseUserLevel }) } });
const result = serverReducer(state, Actions.adjustMod({ userName: 'Dan', shouldBeMod: false, shouldBeJudge: false }));
// IsUser(1) | IsRegistered(2) = 3
expect(result.users[0].userLevel).toBe(3);
expect(result.users['Dan'].userLevel).toBe(3);
});
it('shouldBeMod=true on IsUser|IsRegistered only → produces 7, not 4', () => {
const state = makeServerState({ users: [makeUser({ name: 'Dan', userLevel: UserLevelFlag.IsUser | UserLevelFlag.IsRegistered })] });
const result = serverReducer(state, { type: Types.ADJUST_MOD, userName: 'Dan', shouldBeMod: true, shouldBeJudge: false });
const state = makeServerState({
users: { Dan: makeUser({ name: 'Dan', userLevel: UserLevelFlag.IsUser | UserLevelFlag.IsRegistered }) },
});
const result = serverReducer(state, Actions.adjustMod({ userName: 'Dan', shouldBeMod: true, shouldBeJudge: false }));
// IsUser(1) | IsRegistered(2) | IsModerator(4) = 7
expect(result.users[0].userLevel).toBe(7);
expect(result.users['Dan'].userLevel).toBe(7);
});
it('non-matching users are left unchanged', () => {
const alice = makeUser({ name: 'Alice', userLevel: 7 });
const state = makeServerState({ users: [alice, makeUser({ name: 'Dan', userLevel: baseUserLevel })] });
const result = serverReducer(state, { type: Types.ADJUST_MOD, userName: 'Dan', shouldBeMod: false, shouldBeJudge: false });
expect(result.users.find(u => u.name === 'Alice').userLevel).toBe(7);
const state = makeServerState({
users: { Alice: alice, Dan: makeUser({ name: 'Dan', userLevel: baseUserLevel }) },
});
const result = serverReducer(state, Actions.adjustMod({ userName: 'Dan', shouldBeMod: false, shouldBeJudge: false }));
expect(result.users['Alice']).toEqual(alice);
});
it('unknown userName → state unchanged', () => {
const state = makeServerState({ users: { Dan: makeUser({ name: 'Dan' }) } });
const result = serverReducer(state, Actions.adjustMod({ userName: 'Ghost', shouldBeMod: true, shouldBeJudge: false }));
expect(result).toEqual(state);
});
});
// ── Replays ───────────────────────────────────────────────────────────────────
describe('Replays', () => {
it('REPLAY_LIST → replaces replays list', () => {
it('REPLAY_LIST → replaces replays map keyed by gameId', () => {
const matchList = [makeReplayMatch({ gameId: 10 })];
const state = makeServerState({ replays: [makeReplayMatch({ gameId: 99 })] });
const result = serverReducer(state, { type: Types.REPLAY_LIST, matchList });
expect(result.replays).toHaveLength(1);
expect(result.replays[0].gameId).toBe(10);
const state = makeServerState({ replays: { 99: makeReplayMatch({ gameId: 99 }) } });
const result = serverReducer(state, Actions.replayList({ matchList }));
expect(Object.keys(result.replays)).toHaveLength(1);
expect(result.replays[10]).toBeDefined();
expect(result.replays[99]).toBeUndefined();
});
it('REPLAY_ADDED → appends matchInfo to replays', () => {
it('REPLAY_ADDED → inserts matchInfo into replays map', () => {
const existing = makeReplayMatch({ gameId: 1 });
const added = makeReplayMatch({ gameId: 2 });
const state = makeServerState({ replays: [existing] });
const result = serverReducer(state, { type: Types.REPLAY_ADDED, matchInfo: added });
expect(result.replays).toHaveLength(2);
expect(result.replays[1]).toBe(added);
const state = makeServerState({ replays: { 1: existing } });
const result = serverReducer(state, Actions.replayAdded({ matchInfo: added }));
expect(Object.keys(result.replays)).toHaveLength(2);
expect(result.replays[2]).toEqual(added);
});
it('REPLAY_MODIFY_MATCH → updates doNotHide for matching gameId', () => {
const state = makeServerState({ replays: [makeReplayMatch({ gameId: 5, doNotHide: false })] });
const result = serverReducer(state, { type: Types.REPLAY_MODIFY_MATCH, gameId: 5, doNotHide: true });
expect(result.replays[0].doNotHide).toBe(true);
const state = makeServerState({ replays: { 5: makeReplayMatch({ gameId: 5, doNotHide: false }) } });
const result = serverReducer(state, Actions.replayModifyMatch({ gameId: 5, doNotHide: true }));
expect(result.replays[5].doNotHide).toBe(true);
});
it('REPLAY_MODIFY_MATCH → leaves non-matching replays unchanged', () => {
const r1 = makeReplayMatch({ gameId: 1, doNotHide: false });
const r2 = makeReplayMatch({ gameId: 2, doNotHide: false });
const state = makeServerState({ replays: [r1, r2] });
const result = serverReducer(state, { type: Types.REPLAY_MODIFY_MATCH, gameId: 1, doNotHide: true });
expect(result.replays[1].doNotHide).toBe(false);
const state = makeServerState({ replays: { 1: r1, 2: r2 } });
const result = serverReducer(state, Actions.replayModifyMatch({ gameId: 1, doNotHide: true }));
expect(result.replays[2]).toEqual(r2);
expect(result.replays[2].doNotHide).toBe(false);
});
it('REPLAY_MODIFY_MATCH → unknown gameId → state unchanged', () => {
const state = makeServerState({ replays: { 5: makeReplayMatch({ gameId: 5 }) } });
const result = serverReducer(state, Actions.replayModifyMatch({ gameId: 999, doNotHide: true }));
expect(result).toEqual(state);
});
it('REPLAY_DELETE_MATCH → removes replay by gameId', () => {
const state = makeServerState({ replays: [makeReplayMatch({ gameId: 5 }), makeReplayMatch({ gameId: 6 })] });
const result = serverReducer(state, { type: Types.REPLAY_DELETE_MATCH, gameId: 5 });
expect(result.replays).toHaveLength(1);
expect(result.replays[0].gameId).toBe(6);
const state = makeServerState({
replays: { 5: makeReplayMatch({ gameId: 5 }), 6: makeReplayMatch({ gameId: 6 }) },
});
const result = serverReducer(state, Actions.replayDeleteMatch({ gameId: 5 }));
expect(Object.keys(result.replays)).toHaveLength(1);
expect(result.replays[5]).toBeUndefined();
expect(result.replays[6]).toBeDefined();
});
});
@ -464,22 +488,22 @@ describe('Deck Storage', () => {
it('BACKEND_DECKS → sets backendDecks', () => {
const deckList = makeDeckList();
const state = makeServerState();
const result = serverReducer(state, { type: Types.BACKEND_DECKS, deckList });
expect(result.backendDecks).toBe(deckList);
const result = serverReducer(state, Actions.backendDecks({ deckList }));
expect(result.backendDecks).toEqual(deckList);
});
it('DECK_UPLOAD with null backendDecks → returns state unchanged', () => {
const state = makeServerState({ backendDecks: null });
const result = serverReducer(state, { type: Types.DECK_UPLOAD, path: '', treeItem: makeDeckTreeItem() });
expect(result).toBe(state);
const result = serverReducer(state, Actions.deckUpload({ path: '', treeItem: makeDeckTreeItem() }));
expect(result).toEqual(state);
});
it('DECK_UPLOAD with flat path → appends item to root', () => {
const state = makeServerState({ backendDecks: makeDeckList() });
const item = makeDeckTreeItem({ name: 'deck.cod' });
const result = serverReducer(state, { type: Types.DECK_UPLOAD, path: '', treeItem: item });
expect(result.backendDecks.root.items).toHaveLength(1);
expect(result.backendDecks.root.items[0]).toBe(item);
const result = serverReducer(state, Actions.deckUpload({ path: '', treeItem: item }));
expect(result.backendDecks!.root!.items).toHaveLength(1);
expect(result.backendDecks!.root!.items[0]).toEqual(item);
});
it('DECK_UPLOAD with nested path → inserts into matching subfolder', () => {
@ -490,25 +514,25 @@ describe('Deck Storage', () => {
backendDecks: makeDeckList({ root: create(Data.ServerInfo_DeckStorage_FolderSchema, { items: [subfolder] }) })
});
const item = makeDeckTreeItem({ name: 'new.cod' });
const result = serverReducer(state, { type: Types.DECK_UPLOAD, path: 'myDecks', treeItem: item });
const folder = result.backendDecks.root.items.find(i => i.name === 'myDecks');
expect(folder.folder.items).toHaveLength(1);
expect(folder.folder.items[0]).toBe(item);
const result = serverReducer(state, Actions.deckUpload({ path: 'myDecks', treeItem: item }));
const folder = result.backendDecks!.root!.items.find(i => i.name === 'myDecks');
expect(folder!.folder!.items).toHaveLength(1);
expect(folder!.folder!.items[0]).toEqual(item);
});
it('DECK_UPLOAD with non-existent intermediate folder → creates folder and inserts', () => {
const state = makeServerState({ backendDecks: makeDeckList() });
const item = makeDeckTreeItem({ name: 'deck.cod' });
const result = serverReducer(state, { type: Types.DECK_UPLOAD, path: 'newFolder', treeItem: item });
expect(result.backendDecks.root.items).toHaveLength(1);
expect(result.backendDecks.root.items[0].name).toBe('newFolder');
expect(result.backendDecks.root.items[0].folder.items[0]).toBe(item);
const result = serverReducer(state, Actions.deckUpload({ path: 'newFolder', treeItem: item }));
expect(result.backendDecks!.root!.items).toHaveLength(1);
expect(result.backendDecks!.root!.items[0].name).toBe('newFolder');
expect(result.backendDecks!.root!.items[0].folder!.items[0]).toEqual(item);
});
it('DECK_DELETE with null backendDecks → returns state unchanged', () => {
const state = makeServerState({ backendDecks: null });
const result = serverReducer(state, { type: Types.DECK_DELETE, deckId: 1 });
expect(result).toBe(state);
const result = serverReducer(state, Actions.deckDelete({ deckId: 1 }));
expect(result).toEqual(state);
});
it('DECK_DELETE → removes item by id from tree', () => {
@ -516,8 +540,8 @@ describe('Deck Storage', () => {
const state = makeServerState({
backendDecks: makeDeckList({ root: create(Data.ServerInfo_DeckStorage_FolderSchema, { items: [item] }) }),
});
const result = serverReducer(state, { type: Types.DECK_DELETE, deckId: 7 });
expect(result.backendDecks.root.items).toHaveLength(0);
const result = serverReducer(state, Actions.deckDelete({ deckId: 7 }));
expect(result.backendDecks!.root!.items).toHaveLength(0);
});
it('DECK_DELETE → recursively removes item nested inside a subfolder', () => {
@ -528,22 +552,22 @@ describe('Deck Storage', () => {
const state = makeServerState({
backendDecks: makeDeckList({ root: create(Data.ServerInfo_DeckStorage_FolderSchema, { items: [subfolder] }) })
});
const result = serverReducer(state, { type: Types.DECK_DELETE, deckId: 9 });
expect(result.backendDecks.root.items[0].folder.items).toHaveLength(0);
const result = serverReducer(state, Actions.deckDelete({ deckId: 9 }));
expect(result.backendDecks!.root!.items[0].folder!.items).toHaveLength(0);
});
it('DECK_NEW_DIR with null backendDecks → returns state unchanged', () => {
const state = makeServerState({ backendDecks: null });
const result = serverReducer(state, { type: Types.DECK_NEW_DIR, path: '', dirName: 'newDir' });
expect(result).toBe(state);
const result = serverReducer(state, Actions.deckNewDir({ path: '', dirName: 'newDir' }));
expect(result).toEqual(state);
});
it('DECK_NEW_DIR at root → appends folder to root items', () => {
const state = makeServerState({ backendDecks: makeDeckList() });
const result = serverReducer(state, { type: Types.DECK_NEW_DIR, path: '', dirName: 'myDir' });
expect(result.backendDecks.root.items).toHaveLength(1);
expect(result.backendDecks.root.items[0].name).toBe('myDir');
expect(result.backendDecks.root.items[0].folder.items).toEqual([]);
const result = serverReducer(state, Actions.deckNewDir({ path: '', dirName: 'myDir' }));
expect(result.backendDecks!.root!.items).toHaveLength(1);
expect(result.backendDecks!.root!.items[0].name).toBe('myDir');
expect(result.backendDecks!.root!.items[0].folder!.items).toEqual([]);
});
it('DECK_NEW_DIR nested → inserts folder inside matching subfolder', () => {
@ -553,16 +577,16 @@ describe('Deck Storage', () => {
const state = makeServerState({
backendDecks: makeDeckList({ root: create(Data.ServerInfo_DeckStorage_FolderSchema, { items: [subfolder] }) })
});
const result = serverReducer(state, { type: Types.DECK_NEW_DIR, path: 'parent', dirName: 'child' });
const parent = result.backendDecks.root.items.find(i => i.name === 'parent');
expect(parent.folder.items).toHaveLength(1);
expect(parent.folder.items[0].name).toBe('child');
const result = serverReducer(state, Actions.deckNewDir({ path: 'parent', dirName: 'child' }));
const parent = result.backendDecks!.root!.items.find(i => i.name === 'parent');
expect(parent!.folder!.items).toHaveLength(1);
expect(parent!.folder!.items[0].name).toBe('child');
});
it('DECK_DEL_DIR with null backendDecks → returns state unchanged', () => {
const state = makeServerState({ backendDecks: null });
const result = serverReducer(state, { type: Types.DECK_DEL_DIR, path: 'myDir' });
expect(result).toBe(state);
const result = serverReducer(state, Actions.deckDelDir({ path: 'myDir' }));
expect(result).toEqual(state);
});
it('DECK_DEL_DIR → removes folder from root by name', () => {
@ -572,8 +596,8 @@ describe('Deck Storage', () => {
const state = makeServerState({
backendDecks: makeDeckList({ root: create(Data.ServerInfo_DeckStorage_FolderSchema, { items: [subfolder] }) })
});
const result = serverReducer(state, { type: Types.DECK_DEL_DIR, path: 'myDir' });
expect(result.backendDecks.root.items).toHaveLength(0);
const result = serverReducer(state, Actions.deckDelDir({ path: 'myDir' }));
expect(result.backendDecks!.root!.items).toHaveLength(0);
});
it('DECK_DEL_DIR → returns deck tree unchanged when path is empty', () => {
@ -583,8 +607,8 @@ describe('Deck Storage', () => {
const state = makeServerState({
backendDecks: makeDeckList({ root: create(Data.ServerInfo_DeckStorage_FolderSchema, { items: [subfolder] }) })
});
const result = serverReducer(state, { type: Types.DECK_DEL_DIR, path: '' });
expect(result.backendDecks.root.items).toHaveLength(1);
const result = serverReducer(state, Actions.deckDelDir({ path: '' }));
expect(result.backendDecks!.root!.items).toHaveLength(1);
});
it('DECK_DEL_DIR → recursively removes nested subfolder via multi-segment path', () => {
@ -597,8 +621,8 @@ describe('Deck Storage', () => {
const state = makeServerState({
backendDecks: makeDeckList({ root: create(Data.ServerInfo_DeckStorage_FolderSchema, { items: [parent] }) })
});
const result = serverReducer(state, { type: Types.DECK_DEL_DIR, path: 'parent/child' });
expect(result.backendDecks.root.items[0].folder.items).toHaveLength(0);
const result = serverReducer(state, Actions.deckDelDir({ path: 'parent/child' }));
expect(result.backendDecks!.root!.items[0].folder!.items).toHaveLength(0);
});
});
@ -611,7 +635,7 @@ describe('GAMES_OF_USER', () => {
roomList: [],
});
const state = makeServerState();
const result = serverReducer(state, { type: Types.GAMES_OF_USER, userName: 'alice', response });
const result = serverReducer(state, Actions.gamesOfUser({ userName: 'alice', response }));
expect(result.gamesOfUser['alice']).toEqual([makeGame({ gameId: 5 })]);
});
@ -622,7 +646,7 @@ describe('GAMES_OF_USER', () => {
roomList: [],
});
const state = makeServerState({ gamesOfUser: { alice: old } });
const result = serverReducer(state, { type: Types.GAMES_OF_USER, userName: 'alice', response });
const result = serverReducer(state, Actions.gamesOfUser({ userName: 'alice', response }));
expect(result.gamesOfUser['alice']).toEqual([makeGame({ gameId: 2 })]);
});
@ -630,7 +654,7 @@ describe('GAMES_OF_USER', () => {
const bobGames = [makeGame({ gameId: 3 })];
const response = create(Data.Response_GetGamesOfUserSchema, { gameList: [], roomList: [] });
const state = makeServerState({ gamesOfUser: { bob: bobGames } });
const result = serverReducer(state, { type: Types.GAMES_OF_USER, userName: 'alice', response });
expect(result.gamesOfUser['bob']).toBe(bobGames);
const result = serverReducer(state, Actions.gamesOfUser({ userName: 'alice', response }));
expect(result.gamesOfUser['bob']).toEqual(bobGames);
});
});

View file

@ -1,11 +1,10 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { App, Data } from '@app/types';
import { create } from '@bufbuild/protobuf';
import { normalizeBannedUserError, normalizeGameObject, normalizeGametypeMap, normalizeLogs, SortUtil } from '../common';
import { normalizeBannedUserError, normalizeGameObject, normalizeGametypeMap, normalizeLogs } from '../common';
import { ServerAction } from './server.actions';
import { ServerState } from './server.interfaces'
import { Types } from './server.types';
import { ServerState, ServerStateStatus } from './server.interfaces';
function splitPath(path: string): string[] {
return path ? path.split('/') : [];
@ -67,8 +66,8 @@ function removeByPath(folder: Data.ServerInfo_DeckStorage_Folder, pathSegments:
const initialState: ServerState = {
initialized: false,
buddyList: [],
ignoreList: [],
buddyList: {},
ignoreList: {},
status: {
connectionAttemptMade: false,
@ -86,7 +85,7 @@ const initialState: ServerState = {
chat: []
},
user: null,
users: [],
users: {},
sortUsersBy: {
field: App.UserSortField.NAME,
order: App.SortDirection.ASC
@ -101,452 +100,303 @@ const initialState: ServerState = {
warnListOptions: [],
warnUser: '',
adminNotes: {},
replays: [],
replays: {},
backendDecks: null,
gamesOfUser: {},
registrationError: null,
};
export const serverReducer = (state = initialState, action: ServerAction) => {
switch (action.type) {
case Types.INITIALIZED: {
return {
...initialState,
initialized: true
export const serverSlice = createSlice({
name: 'server',
initialState,
reducers: {
initialized: () => ({
...initialState,
initialized: true,
}),
connectionAttempted: (state) => {
state.status.connectionAttemptMade = true;
},
clearStore: (state) => ({
...initialState,
status: { ...state.status },
}),
serverMessage: (state, action: PayloadAction<{ message: string }>) => {
state.info.message = action.payload.message;
},
updateBuddyList: (state, action: PayloadAction<{ buddyList: Data.ServerInfo_User[] }>) => {
const buddyList: { [userName: string]: Data.ServerInfo_User } = {};
for (const user of action.payload.buddyList) {
buddyList[user.name] = user;
}
}
case Types.CONNECTION_ATTEMPTED: {
return {
...state,
status: { ...state.status, connectionAttemptMade: true }
};
}
case Types.ACCOUNT_AWAITING_ACTIVATION: {
return state;
}
case Types.ACCOUNT_ACTIVATION_FAILED:
case Types.ACCOUNT_ACTIVATION_SUCCESS: {
return state;
}
case Types.CLEAR_STORE: {
return {
...initialState,
status: {
...state.status
}
state.buddyList = buddyList;
},
addToBuddyList: (state, action: PayloadAction<{ user: Data.ServerInfo_User }>) => {
const { user } = action.payload;
state.buddyList[user.name] = user;
},
removeFromBuddyList: (state, action: PayloadAction<{ userName: string }>) => {
delete state.buddyList[action.payload.userName];
},
updateIgnoreList: (state, action: PayloadAction<{ ignoreList: Data.ServerInfo_User[] }>) => {
const ignoreList: { [userName: string]: Data.ServerInfo_User } = {};
for (const user of action.payload.ignoreList) {
ignoreList[user.name] = user;
}
}
case Types.SERVER_MESSAGE: {
const { message } = action;
const { info } = state;
state.ignoreList = ignoreList;
},
return {
...state,
info: { ...info, message }
}
}
case Types.UPDATE_BUDDY_LIST: {
const { buddyList } = action;
const { sortUsersBy } = state;
addToIgnoreList: (state, action: PayloadAction<{ user: Data.ServerInfo_User }>) => {
const { user } = action.payload;
state.ignoreList[user.name] = user;
},
SortUtil.sortUsersByField(buddyList, sortUsersBy);
removeFromIgnoreList: (state, action: PayloadAction<{ userName: string }>) => {
delete state.ignoreList[action.payload.userName];
},
return {
...state,
buddyList: [
...buddyList
]
};
}
case Types.ADD_TO_BUDDY_LIST: {
const { user } = action;
const { sortUsersBy } = state;
updateInfo: (state, action: PayloadAction<{ info: { name: string; version: string } }>) => {
const { name, version } = action.payload.info;
state.info.name = name;
state.info.version = version;
},
const buddyList = [...state.buddyList];
buddyList.push(user);
SortUtil.sortUsersByField(buddyList, sortUsersBy);
return {
...state,
buddyList
};
}
case Types.REMOVE_FROM_BUDDY_LIST: {
const { userName } = action;
const buddyList = state.buddyList.filter(user => user.name !== userName);
return {
...state,
buddyList
};
}
case Types.UPDATE_IGNORE_LIST: {
const { ignoreList } = action;
const { sortUsersBy } = state;
SortUtil.sortUsersByField(ignoreList, sortUsersBy);
return {
...state,
ignoreList: [
...ignoreList
]
};
}
case Types.ADD_TO_IGNORE_LIST: {
const { user } = action;
const { sortUsersBy } = state;
const ignoreList = [...state.ignoreList];
ignoreList.push(user);
SortUtil.sortUsersByField(ignoreList, sortUsersBy);
return {
...state,
ignoreList
};
}
case Types.REMOVE_FROM_IGNORE_LIST: {
const { userName } = action;
const ignoreList = state.ignoreList.filter(user => user.name !== userName);
return {
...state,
ignoreList
};
}
case Types.UPDATE_INFO: {
const { name, version } = action.info;
const { info } = state;
return {
...state,
info: { ...info, name, version }
}
}
case Types.UPDATE_STATUS: {
const { status } = action;
const newState = {
...state,
status: { ...state.status, ...status }
};
updateStatus: (state, action: PayloadAction<{ status: Pick<ServerStateStatus, 'state' | 'description'> }>) => {
const { status } = action.payload;
state.status = { ...state.status, ...status };
if (status.state === App.StatusEnum.DISCONNECTED) {
return {
...newState,
status: { ...newState.status, connectionAttemptMade: false }
};
state.status.connectionAttemptMade = false;
}
},
return newState;
}
case Types.UPDATE_USER:
case Types.ACCOUNT_EDIT_CHANGED:
case Types.ACCOUNT_IMAGE_CHANGED: {
const { user } = action;
updateUser: (state, action: PayloadAction<{ user: Data.ServerInfo_User | Partial<Data.ServerInfo_User> }>) => {
state.user = { ...state.user, ...action.payload.user } as Data.ServerInfo_User;
},
return {
...state,
user: {
...state.user,
...user
}
updateUsers: (state, action: PayloadAction<{ users: Data.ServerInfo_User[] }>) => {
const users: { [userName: string]: Data.ServerInfo_User } = {};
for (const user of action.payload.users) {
users[user.name] = user;
}
}
case Types.UPDATE_USERS: {
const users = [...action.users];
const { sortUsersBy } = state;
state.users = users;
},
userJoined: (state, action: PayloadAction<{ user: Data.ServerInfo_User }>) => {
const { user } = action.payload;
state.users[user.name] = user;
},
SortUtil.sortUsersByField(users, sortUsersBy);
userLeft: (state, action: PayloadAction<{ name: string }>) => {
delete state.users[action.payload.name];
},
return {
...state,
users
};
}
case Types.USER_JOINED: {
const { sortUsersBy } = state;
viewLogs: (state, action: PayloadAction<{ logs: Data.ServerInfo_ChatMessage[] }>) => {
state.logs = { ...normalizeLogs(action.payload.logs) };
},
const users = [
...state.users,
{ ...action.user }
];
clearLogs: (state) => {
state.logs = { ...initialState.logs };
},
SortUtil.sortUsersByField(users, sortUsersBy);
return {
...state,
users
};
}
case Types.USER_LEFT: {
const { name } = action;
const users = state.users.filter(user => user.name !== name);
return {
...state,
users
};
}
case Types.VIEW_LOGS: {
const { logs } = action;
return {
...state,
logs: {
...normalizeLogs(logs)
}
};
}
case Types.CLEAR_LOGS: {
return {
...state,
logs: {
...initialState.logs
}
userMessage: (state, action: PayloadAction<{ messageData: Data.Event_UserMessage }>) => {
const { senderName, receiverName } = action.payload.messageData;
const userName = state.user!.name === senderName ? receiverName : senderName;
if (!state.messages[userName]) {
state.messages[userName] = [];
}
}
case Types.USER_MESSAGE: {
const { senderName, receiverName } = action.messageData;
const userName = state.user.name === senderName ? receiverName : senderName;
state.messages[userName].push(action.payload.messageData);
},
return {
...state,
messages: {
...state.messages,
[userName]: [
...(state.messages[userName] ?? []),
action.messageData,
],
}
};
}
case Types.GET_USER_INFO: {
const { userInfo } = action;
getUserInfo: (state, action: PayloadAction<{ userInfo: Data.ServerInfo_User }>) => {
const { userInfo } = action.payload;
state.userInfo[userInfo.name] = userInfo;
},
return {
...state,
userInfo: {
...state.userInfo,
[userInfo.name]: userInfo,
}
};
}
case Types.NOTIFY_USER: {
const { notification } = action;
notifyUser: (state, action: PayloadAction<{ notification: Data.Event_NotifyUser }>) => {
state.notifications.push(action.payload.notification);
},
return {
...state,
notifications: [
...state.notifications,
notification
]
};
}
case Types.SERVER_SHUTDOWN: {
const { data } = action;
serverShutdown: (state, action: PayloadAction<{ data: Data.Event_ServerShutdown }>) => {
state.serverShutdown = action.payload.data;
},
return {
...state,
serverShutdown: data,
};
}
case Types.BAN_FROM_SERVER: {
const { userName } = action;
banFromServer: (state, action: PayloadAction<{ userName: string }>) => {
state.banUser = action.payload.userName;
},
return {
...state,
banUser: userName,
};
}
case Types.BAN_HISTORY: {
const { userName, banHistory } = action;
banHistory: (state, action: PayloadAction<{ userName: string; banHistory: Data.ServerInfo_Ban[] }>) => {
state.banHistory[action.payload.userName] = action.payload.banHistory;
},
return {
...state,
banHistory: {
...state.banHistory,
[userName]: banHistory,
}
};
}
case Types.WARN_HISTORY: {
const { userName, warnHistory } = action;
warnHistory: (state, action: PayloadAction<{ userName: string; warnHistory: Data.ServerInfo_Warning[] }>) => {
state.warnHistory[action.payload.userName] = action.payload.warnHistory;
},
return {
...state,
warnHistory: {
...state.warnHistory,
[userName]: warnHistory,
}
};
}
case Types.WARN_LIST_OPTIONS: {
const { warnList } = action;
warnListOptions: (state, action: PayloadAction<{ warnList: Data.Response_WarnList[] }>) => {
state.warnListOptions = action.payload.warnList;
},
return {
...state,
warnListOptions: warnList,
};
}
case Types.WARN_USER: {
const { userName } = action;
return {
...state,
warnUser: userName,
};
}
case Types.GET_ADMIN_NOTES:
case Types.UPDATE_ADMIN_NOTES: {
const { userName, notes } = action;
return {
...state,
adminNotes: {
...state.adminNotes,
[userName]: notes,
}
};
}
case Types.ADJUST_MOD: {
const { userName, shouldBeMod, shouldBeJudge } = action;
warnUser: (state, action: PayloadAction<{ userName: string }>) => {
state.warnUser = action.payload.userName;
},
return {
...state,
users: state.users.map((user) => {
if (user.name !== userName) {
return user;
}
let newLevel = user.userLevel;
newLevel = shouldBeMod
? (newLevel | Data.ServerInfo_User_UserLevelFlag.IsModerator)
: (newLevel & ~Data.ServerInfo_User_UserLevelFlag.IsModerator);
newLevel = shouldBeJudge
? (newLevel | Data.ServerInfo_User_UserLevelFlag.IsJudge)
: (newLevel & ~Data.ServerInfo_User_UserLevelFlag.IsJudge);
return {
...user,
userLevel: newLevel,
}
})
};
}
case Types.REPLAY_LIST: {
return { ...state, replays: [...action.matchList] };
}
case Types.REPLAY_ADDED: {
return { ...state, replays: [...state.replays, action.matchInfo] };
}
case Types.REPLAY_MODIFY_MATCH: {
return {
...state,
replays: state.replays.map(r =>
r.gameId === action.gameId ? { ...r, doNotHide: action.doNotHide } : r
),
};
}
case Types.REPLAY_DELETE_MATCH: {
return { ...state, replays: state.replays.filter(r => r.gameId !== action.gameId) };
}
case Types.BACKEND_DECKS: {
return { ...state, backendDecks: action.deckList };
}
case Types.DECK_UPLOAD: {
getAdminNotes: (state, action: PayloadAction<{ userName: string; notes: string }>) => {
state.adminNotes[action.payload.userName] = action.payload.notes;
},
updateAdminNotes: (state, action: PayloadAction<{ userName: string; notes: string }>) => {
state.adminNotes[action.payload.userName] = action.payload.notes;
},
adjustMod: (state, action: PayloadAction<{ userName: string; shouldBeMod: boolean; shouldBeJudge: boolean }>) => {
const { userName, shouldBeMod, shouldBeJudge } = action.payload;
const user = state.users[userName];
if (!user) {
return;
}
let newLevel = user.userLevel;
newLevel = shouldBeMod
? (newLevel | Data.ServerInfo_User_UserLevelFlag.IsModerator)
: (newLevel & ~Data.ServerInfo_User_UserLevelFlag.IsModerator);
newLevel = shouldBeJudge
? (newLevel | Data.ServerInfo_User_UserLevelFlag.IsJudge)
: (newLevel & ~Data.ServerInfo_User_UserLevelFlag.IsJudge);
state.users[userName] = { ...user, userLevel: newLevel };
},
replayList: (state, action: PayloadAction<{ matchList: Data.ServerInfo_ReplayMatch[] }>) => {
const replays: { [gameId: number]: Data.ServerInfo_ReplayMatch } = {};
for (const match of action.payload.matchList) {
replays[match.gameId] = match;
}
state.replays = replays;
},
replayAdded: (state, action: PayloadAction<{ matchInfo: Data.ServerInfo_ReplayMatch }>) => {
const { matchInfo } = action.payload;
state.replays[matchInfo.gameId] = matchInfo;
},
replayModifyMatch: (state, action: PayloadAction<{ gameId: number; doNotHide: boolean }>) => {
const { gameId, doNotHide } = action.payload;
const existing = state.replays[gameId];
if (!existing) {
return;
}
state.replays[gameId] = { ...existing, doNotHide };
},
replayDeleteMatch: (state, action: PayloadAction<{ gameId: number }>) => {
delete state.replays[action.payload.gameId];
},
backendDecks: (state, action: PayloadAction<{ deckList: Data.Response_DeckList }>) => {
state.backendDecks = action.payload.deckList;
},
deckUpload: (state, action: PayloadAction<{ path: string; treeItem: Data.ServerInfo_DeckStorage_TreeItem }>) => {
if (!state.backendDecks?.root) {
return state;
return;
}
return {
...state,
backendDecks: create(Data.Response_DeckListSchema, {
root: insertAtPath(state.backendDecks.root, splitPath(action.path), action.treeItem),
}),
};
}
case Types.DECK_DELETE: {
state.backendDecks = create(Data.Response_DeckListSchema, {
root: insertAtPath(state.backendDecks.root, splitPath(action.payload.path), action.payload.treeItem),
});
},
deckDelete: (state, action: PayloadAction<{ deckId: number }>) => {
if (!state.backendDecks?.root) {
return state;
return;
}
return {
...state,
backendDecks: create(Data.Response_DeckListSchema, {
root: removeById(state.backendDecks.root, action.deckId),
}),
};
}
case Types.DECK_NEW_DIR: {
state.backendDecks = create(Data.Response_DeckListSchema, {
root: removeById(state.backendDecks.root, action.payload.deckId),
});
},
deckNewDir: (state, action: PayloadAction<{ path: string; dirName: string }>) => {
if (!state.backendDecks?.root) {
return state;
return;
}
const newFolder: Data.ServerInfo_DeckStorage_TreeItem = create(Data.ServerInfo_DeckStorage_TreeItemSchema, {
id: 0, name: action.dirName, folder: create(Data.ServerInfo_DeckStorage_FolderSchema, { items: [] })
id: 0, name: action.payload.dirName, folder: create(Data.ServerInfo_DeckStorage_FolderSchema, { items: [] })
});
return {
...state,
backendDecks: create(Data.Response_DeckListSchema, {
root: insertAtPath(state.backendDecks.root, splitPath(action.path), newFolder),
}),
};
}
case Types.DECK_DEL_DIR: {
state.backendDecks = create(Data.Response_DeckListSchema, {
root: insertAtPath(state.backendDecks.root, splitPath(action.payload.path), newFolder),
});
},
deckDelDir: (state, action: PayloadAction<{ path: string }>) => {
if (!state.backendDecks?.root) {
return state;
return;
}
return {
...state,
backendDecks: create(Data.Response_DeckListSchema, {
root: removeByPath(state.backendDecks.root, splitPath(action.path)),
}),
};
}
case Types.GAMES_OF_USER: {
const { userName, response } = action;
state.backendDecks = create(Data.Response_DeckListSchema, {
root: removeByPath(state.backendDecks.root, splitPath(action.payload.path)),
});
},
gamesOfUser: (state, action: PayloadAction<{ userName: string; response: Data.Response_GetGamesOfUser }>) => {
const { userName, response } = action.payload;
const gametypeMap = normalizeGametypeMap(
(response.roomList ?? []).flatMap(room => room.gametypeList ?? [])
);
const normalizedGames = (response.gameList ?? []).map(g => normalizeGameObject(g, gametypeMap));
return {
...state,
gamesOfUser: {
...state.gamesOfUser,
[userName]: normalizedGames,
},
};
}
case Types.REGISTRATION_FAILED: {
const error = action.endTime
? normalizeBannedUserError(action.reason, action.endTime)
: action.reason;
return { ...state, registrationError: error };
}
case Types.CLEAR_REGISTRATION_ERRORS:
return { ...state, registrationError: null };
// Signal-only action types — no state mutation, explicit for discriminated-union exhaustiveness
case Types.LOGIN_SUCCESSFUL:
case Types.LOGIN_FAILED:
case Types.CONNECTION_FAILED:
case Types.TEST_CONNECTION_SUCCESSFUL:
case Types.TEST_CONNECTION_FAILED:
case Types.REGISTRATION_REQUIRES_EMAIL:
case Types.REGISTRATION_SUCCESS:
case Types.REGISTRATION_EMAIL_ERROR:
case Types.REGISTRATION_PASSWORD_ERROR:
case Types.REGISTRATION_USERNAME_ERROR:
case Types.RESET_PASSWORD_REQUESTED:
case Types.RESET_PASSWORD_FAILED:
case Types.RESET_PASSWORD_CHALLENGE:
case Types.RESET_PASSWORD_SUCCESS:
case Types.RELOAD_CONFIG:
case Types.SHUTDOWN_SERVER:
case Types.UPDATE_SERVER_MESSAGE:
case Types.ACCOUNT_PASSWORD_CHANGE:
case Types.ADD_TO_LIST:
case Types.REMOVE_FROM_LIST:
case Types.GRANT_REPLAY_ACCESS:
case Types.FORCE_ACTIVATE_USER:
return state;
default:
return state;
}
}
state.gamesOfUser[userName] = normalizedGames;
},
registrationFailed: (state, action: PayloadAction<{ reason: string; endTime?: number }>) => {
const { reason, endTime } = action.payload;
const error = endTime
? normalizeBannedUserError(reason, endTime)
: reason;
state.registrationError = error;
},
clearRegistrationErrors: (state) => {
state.registrationError = null;
},
accountEditChanged: (state, action: PayloadAction<{ user: Partial<Data.ServerInfo_User> }>) => {
state.user = { ...state.user, ...action.payload.user } as Data.ServerInfo_User;
},
accountImageChanged: (state, action: PayloadAction<{ user: Partial<Data.ServerInfo_User> }>) => {
state.user = { ...state.user, ...action.payload.user } as Data.ServerInfo_User;
},
// Signal-only action types — no state mutation, defined so type strings are generated
accountAwaitingActivation: (_state, _action: PayloadAction<any>) => {},
accountActivationFailed: (_state, _action: PayloadAction<any>) => {},
accountActivationSuccess: (_state, _action: PayloadAction<any>) => {},
loginSuccessful: (_state, _action: PayloadAction<any>) => {},
loginFailed: (_state, _action: PayloadAction<any>) => {},
connectionFailed: (_state, _action: PayloadAction<any>) => {},
testConnectionSuccessful: (_state, _action: PayloadAction<any>) => {},
testConnectionFailed: (_state, _action: PayloadAction<any>) => {},
registrationRequiresEmail: (_state, _action: PayloadAction<any>) => {},
registrationSuccess: (_state, _action: PayloadAction<any>) => {},
registrationEmailError: (_state, _action: PayloadAction<any>) => {},
registrationPasswordError: (_state, _action: PayloadAction<any>) => {},
registrationUserNameError: (_state, _action: PayloadAction<any>) => {},
resetPassword: (_state, _action: PayloadAction<any>) => {},
resetPasswordFailed: (_state, _action: PayloadAction<any>) => {},
resetPasswordChallenge: (_state, _action: PayloadAction<any>) => {},
resetPasswordSuccess: (_state, _action: PayloadAction<any>) => {},
reloadConfig: (_state, _action: PayloadAction<any>) => {},
shutdownServer: (_state, _action: PayloadAction<any>) => {},
updateServerMessage: (_state, _action: PayloadAction<any>) => {},
accountPasswordChange: (_state, _action: PayloadAction<any>) => {},
addToList: (_state, _action: PayloadAction<any>) => {},
removeFromList: (_state, _action: PayloadAction<any>) => {},
grantReplayAccess: (_state, _action: PayloadAction<any>) => {},
forceActivateUser: (_state, _action: PayloadAction<any>) => {},
},
});
export const serverReducer = serverSlice.reducer;

View file

@ -54,8 +54,8 @@ describe('Selectors', () => {
expect(Selectors.getUser(rootState(state))).toBe(user);
});
it('getUsers → returns users array', () => {
const users = [makeUser(), makeUser({ name: 'Bob' })];
it('getUsers → returns users keyed map', () => {
const users = { TestUser: makeUser(), Bob: makeUser({ name: 'Bob' }) };
const state = makeServerState({ users });
expect(Selectors.getUsers(rootState(state))).toBe(users);
});
@ -66,24 +66,62 @@ describe('Selectors', () => {
expect(Selectors.getLogs(rootState(state))).toBe(logs);
});
it('getBuddyList → returns buddyList', () => {
const buddyList = [makeUser({ name: 'Carol' })];
it('getBuddyList → returns buddyList keyed map', () => {
const buddyList = { Carol: makeUser({ name: 'Carol' }) };
const state = makeServerState({ buddyList });
expect(Selectors.getBuddyList(rootState(state))).toBe(buddyList);
});
it('getIgnoreList → returns ignoreList', () => {
const ignoreList = [makeUser({ name: 'Dave' })];
it('getIgnoreList → returns ignoreList keyed map', () => {
const ignoreList = { Dave: makeUser({ name: 'Dave' }) };
const state = makeServerState({ ignoreList });
expect(Selectors.getIgnoreList(rootState(state))).toBe(ignoreList);
});
it('getReplays → returns replays', () => {
const replays = [makeReplayMatch()];
it('getReplays → returns replays keyed map', () => {
const replays = { 1: makeReplayMatch() };
const state = makeServerState({ replays });
expect(Selectors.getReplays(rootState(state))).toBe(replays);
});
it('getSortedUsers → returns user array sorted by name ASC', () => {
const users = { Zane: makeUser({ name: 'Zane' }), Alice: makeUser({ name: 'Alice' }) };
const state = makeServerState({ users });
const sorted = Selectors.getSortedUsers(rootState(state));
expect(sorted[0].name).toBe('Alice');
expect(sorted[1].name).toBe('Zane');
});
it('getSortedUsers → returns EMPTY_USERS for empty map', () => {
const state = makeServerState({ users: {} });
const sorted = Selectors.getSortedUsers(rootState(state));
expect(sorted).toHaveLength(0);
});
it('getSortedBuddyList → returns buddy array sorted by name ASC', () => {
const buddyList = { Zane: makeUser({ name: 'Zane' }), Alice: makeUser({ name: 'Alice' }) };
const state = makeServerState({ buddyList });
const sorted = Selectors.getSortedBuddyList(rootState(state));
expect(sorted[0].name).toBe('Alice');
expect(sorted[1].name).toBe('Zane');
});
it('getSortedIgnoreList → returns ignore array sorted by name ASC', () => {
const ignoreList = { Zane: makeUser({ name: 'Zane' }), Alice: makeUser({ name: 'Alice' }) };
const state = makeServerState({ ignoreList });
const sorted = Selectors.getSortedIgnoreList(rootState(state));
expect(sorted[0].name).toBe('Alice');
expect(sorted[1].name).toBe('Zane');
});
it('getReplaysList → returns replay array sorted by gameId ASC', () => {
const replays = { 10: makeReplayMatch({ gameId: 10 }), 3: makeReplayMatch({ gameId: 3 }) };
const state = makeServerState({ replays });
const sorted = Selectors.getReplaysList(rootState(state));
expect(sorted[0].gameId).toBe(3);
expect(sorted[1].gameId).toBe(10);
});
it('getBackendDecks → returns backendDecks', () => {
const backendDecks = makeDeckList();
const state = makeServerState({ backendDecks });

View file

@ -1,9 +1,15 @@
import { createSelector } from '@reduxjs/toolkit';
import { App, Data } from '@app/types';
import { SortUtil } from '../common';
import { ServerState } from './server.interfaces';
interface State {
server: ServerState
}
const EMPTY_USERS: Data.ServerInfo_User[] = [];
const EMPTY_REPLAYS: Data.ServerInfo_ReplayMatch[] = [];
export const Selectors = {
getInitialized: ({ server }: State) => server.initialized,
getMessage: ({ server }: State) => server.info.message,
@ -13,11 +19,79 @@ export const Selectors = {
getState: ({ server }: State) => server.status.state,
getConnectionAttemptMade: ({ server }: State) => server.status.connectionAttemptMade,
getUser: ({ server }: State) => server.user,
getUsers: ({ server }: State) => server.users,
/** True when the server status has reached LOGGED_IN. */
getIsConnected: createSelector(
[({ server }: State) => server.status.state],
(state): boolean => state === App.StatusEnum.LOGGED_IN
),
/** True when the currently logged-in user has the IsModerator level flag. */
getIsUserModerator: createSelector(
[({ server }: State) => server.user],
(user): boolean => {
if (!user) {
return false;
}
const mask = Data.ServerInfo_User_UserLevelFlag.IsModerator;
return (user.userLevel & mask) === mask;
}
),
getLogs: ({ server }: State) => server.logs,
getBackendDecks: ({ server }: State) => server.backendDecks,
getRegistrationError: ({ server }: State) => server.registrationError,
getSortUsersBy: ({ server }: State) => server.sortUsersBy,
/** Raw keyed maps — use the sorted-view selectors below for display. */
getUsers: ({ server }: State) => server.users,
getBuddyList: ({ server }: State) => server.buddyList,
getIgnoreList: ({ server }: State) => server.ignoreList,
getReplays: ({ server }: State) => server.replays,
getBackendDecks: ({ server }: State) => server.backendDecks,
getRegistrationError: ({ server }: State) => server.registrationError,
/**
* Sorted array views of the keyed maps. Memoized via `createSelector` so
* the array reference is stable until the underlying map or sort config
* actually changes consumers using these in `useAppSelector` won't
* re-render unnecessarily.
*/
getSortedUsers: createSelector(
[(state: State) => state.server.users, (state: State) => state.server.sortUsersBy],
(users, sortBy): Data.ServerInfo_User[] => {
if (!users || Object.keys(users).length === 0) {
return EMPTY_USERS;
}
return SortUtil.sortedUsersByField(Object.values(users), sortBy);
}
),
getSortedBuddyList: createSelector(
[(state: State) => state.server.buddyList, (state: State) => state.server.sortUsersBy],
(buddyList, sortBy): Data.ServerInfo_User[] => {
if (!buddyList || Object.keys(buddyList).length === 0) {
return EMPTY_USERS;
}
return SortUtil.sortedUsersByField(Object.values(buddyList), sortBy);
}
),
getSortedIgnoreList: createSelector(
[(state: State) => state.server.ignoreList, (state: State) => state.server.sortUsersBy],
(ignoreList, sortBy): Data.ServerInfo_User[] => {
if (!ignoreList || Object.keys(ignoreList).length === 0) {
return EMPTY_USERS;
}
return SortUtil.sortedUsersByField(Object.values(ignoreList), sortBy);
}
),
/** Replay list as an array, ordered by gameId ascending for stable display. */
getReplaysList: createSelector(
[(state: State) => state.server.replays],
(replays): Data.ServerInfo_ReplayMatch[] => {
if (!replays || Object.keys(replays).length === 0) {
return EMPTY_REPLAYS;
}
return Object.values(replays).sort((a, b) => a.gameId - b.gameId);
}
),
}

View file

@ -1,74 +1,78 @@
import { serverSlice } from './server.reducer';
const a = serverSlice.actions;
export const Types = {
INITIALIZED: '[Server] Initialized',
CLEAR_STORE: '[Server] Clear Store',
CONNECTION_ATTEMPTED: '[Server] Connection Attempted',
LOGIN_SUCCESSFUL: '[Server] Login Successful',
LOGIN_FAILED: '[Server] Login Failed',
CONNECTION_FAILED: '[Server] Connection Failed',
TEST_CONNECTION_SUCCESSFUL: '[Server] Test Connection Successful',
TEST_CONNECTION_FAILED: '[Server] Test Connection Failed',
SERVER_MESSAGE: '[Server] Server Message',
UPDATE_BUDDY_LIST: '[Server] Update Buddy List',
ADD_TO_BUDDY_LIST: '[Server] Add to Buddy List',
REMOVE_FROM_BUDDY_LIST: '[Server] Remove from Buddy List',
UPDATE_IGNORE_LIST: '[Server] Update Ignore List',
ADD_TO_IGNORE_LIST: '[Server] Add to Ignore List',
REMOVE_FROM_IGNORE_LIST: '[Server] Remove from Ignore List',
UPDATE_INFO: '[Server] Update Info',
UPDATE_STATUS: '[Server] Update Status',
UPDATE_USER: '[Server] Update User',
UPDATE_USERS: '[Server] Update Users',
USER_JOINED: '[Server] User Joined',
USER_LEFT: '[Server] User Left',
VIEW_LOGS: '[Server] View Logs',
CLEAR_LOGS: '[Server] Clear Logs',
REGISTRATION_REQUIRES_EMAIL: '[Server] Registration Requires Email',
REGISTRATION_SUCCESS: '[Server] Registration Success',
REGISTRATION_FAILED: '[Server] Registration Failed',
REGISTRATION_EMAIL_ERROR: '[Server] Registration Email Error',
REGISTRATION_PASSWORD_ERROR: '[Server] Registration Password Error',
REGISTRATION_USERNAME_ERROR: '[Server] Registration Username Error',
CLEAR_REGISTRATION_ERRORS: '[Server] Clear Registration Errors',
ACCOUNT_AWAITING_ACTIVATION: '[Server] Account Awaiting Activation',
ACCOUNT_ACTIVATION_SUCCESS: '[Server] Account Activation Success',
ACCOUNT_ACTIVATION_FAILED: '[Server] Account Activation Failed',
RESET_PASSWORD_REQUESTED: '[Server] Reset Password Requested',
RESET_PASSWORD_FAILED: '[Server] Reset Password Failed',
RESET_PASSWORD_CHALLENGE: '[Server] Reset Password Challenge',
RESET_PASSWORD_SUCCESS: '[Server] Reset Password Success',
ADJUST_MOD: '[Server] Adjust Mod',
RELOAD_CONFIG: '[Server] Reload Config',
SHUTDOWN_SERVER: '[Server] Shutdown Server',
UPDATE_SERVER_MESSAGE: '[Server] Update Server Message',
ACCOUNT_PASSWORD_CHANGE: '[Server] Account Password Change',
ACCOUNT_EDIT_CHANGED: '[Server] Account Edit Changed',
ACCOUNT_IMAGE_CHANGED: '[Server] Account Image Changed',
GET_USER_INFO: '[Server] Get User Info',
NOTIFY_USER: '[Server] Notify User',
SERVER_SHUTDOWN: '[Server] Server Shutdown',
USER_MESSAGE: '[Server] User Message',
ADD_TO_LIST: '[Server] Add To List',
REMOVE_FROM_LIST: '[Server] Remove From List',
BAN_FROM_SERVER: '[Server] Ban From Server',
BAN_HISTORY: '[Server] Ban History',
WARN_HISTORY: '[Server] Warn History',
WARN_LIST_OPTIONS: '[Server] Warn List Options',
WARN_USER: '[Server] Warn User',
GRANT_REPLAY_ACCESS: '[Server] Grant Replay Access',
FORCE_ACTIVATE_USER: '[Server] Force Activate User',
GET_ADMIN_NOTES: '[Server] Get Admin Notes',
UPDATE_ADMIN_NOTES: '[Server] Update Admin Notes',
INITIALIZED: a.initialized.type,
CLEAR_STORE: a.clearStore.type,
CONNECTION_ATTEMPTED: a.connectionAttempted.type,
LOGIN_SUCCESSFUL: a.loginSuccessful.type,
LOGIN_FAILED: a.loginFailed.type,
CONNECTION_FAILED: a.connectionFailed.type,
TEST_CONNECTION_SUCCESSFUL: a.testConnectionSuccessful.type,
TEST_CONNECTION_FAILED: a.testConnectionFailed.type,
SERVER_MESSAGE: a.serverMessage.type,
UPDATE_BUDDY_LIST: a.updateBuddyList.type,
ADD_TO_BUDDY_LIST: a.addToBuddyList.type,
REMOVE_FROM_BUDDY_LIST: a.removeFromBuddyList.type,
UPDATE_IGNORE_LIST: a.updateIgnoreList.type,
ADD_TO_IGNORE_LIST: a.addToIgnoreList.type,
REMOVE_FROM_IGNORE_LIST: a.removeFromIgnoreList.type,
UPDATE_INFO: a.updateInfo.type,
UPDATE_STATUS: a.updateStatus.type,
UPDATE_USER: a.updateUser.type,
UPDATE_USERS: a.updateUsers.type,
USER_JOINED: a.userJoined.type,
USER_LEFT: a.userLeft.type,
VIEW_LOGS: a.viewLogs.type,
CLEAR_LOGS: a.clearLogs.type,
REGISTRATION_REQUIRES_EMAIL: a.registrationRequiresEmail.type,
REGISTRATION_SUCCESS: a.registrationSuccess.type,
REGISTRATION_FAILED: a.registrationFailed.type,
REGISTRATION_EMAIL_ERROR: a.registrationEmailError.type,
REGISTRATION_PASSWORD_ERROR: a.registrationPasswordError.type,
REGISTRATION_USERNAME_ERROR: a.registrationUserNameError.type,
CLEAR_REGISTRATION_ERRORS: a.clearRegistrationErrors.type,
ACCOUNT_AWAITING_ACTIVATION: a.accountAwaitingActivation.type,
ACCOUNT_ACTIVATION_SUCCESS: a.accountActivationSuccess.type,
ACCOUNT_ACTIVATION_FAILED: a.accountActivationFailed.type,
RESET_PASSWORD_REQUESTED: a.resetPassword.type,
RESET_PASSWORD_FAILED: a.resetPasswordFailed.type,
RESET_PASSWORD_CHALLENGE: a.resetPasswordChallenge.type,
RESET_PASSWORD_SUCCESS: a.resetPasswordSuccess.type,
ADJUST_MOD: a.adjustMod.type,
RELOAD_CONFIG: a.reloadConfig.type,
SHUTDOWN_SERVER: a.shutdownServer.type,
UPDATE_SERVER_MESSAGE: a.updateServerMessage.type,
ACCOUNT_PASSWORD_CHANGE: a.accountPasswordChange.type,
ACCOUNT_EDIT_CHANGED: a.accountEditChanged.type,
ACCOUNT_IMAGE_CHANGED: a.accountImageChanged.type,
GET_USER_INFO: a.getUserInfo.type,
NOTIFY_USER: a.notifyUser.type,
SERVER_SHUTDOWN: a.serverShutdown.type,
USER_MESSAGE: a.userMessage.type,
ADD_TO_LIST: a.addToList.type,
REMOVE_FROM_LIST: a.removeFromList.type,
BAN_FROM_SERVER: a.banFromServer.type,
BAN_HISTORY: a.banHistory.type,
WARN_HISTORY: a.warnHistory.type,
WARN_LIST_OPTIONS: a.warnListOptions.type,
WARN_USER: a.warnUser.type,
GRANT_REPLAY_ACCESS: a.grantReplayAccess.type,
FORCE_ACTIVATE_USER: a.forceActivateUser.type,
GET_ADMIN_NOTES: a.getAdminNotes.type,
UPDATE_ADMIN_NOTES: a.updateAdminNotes.type,
// Replay
REPLAY_LIST: '[Server] Replay List',
REPLAY_ADDED: '[Server] Replay Added',
REPLAY_MODIFY_MATCH: '[Server] Replay Modify Match',
REPLAY_DELETE_MATCH: '[Server] Replay Delete Match',
REPLAY_LIST: a.replayList.type,
REPLAY_ADDED: a.replayAdded.type,
REPLAY_MODIFY_MATCH: a.replayModifyMatch.type,
REPLAY_DELETE_MATCH: a.replayDeleteMatch.type,
// Deck Storage
BACKEND_DECKS: '[Server] Backend Decks',
DECK_NEW_DIR: '[Server] Deck New Dir',
DECK_DEL_DIR: '[Server] Deck Del Dir',
DECK_UPLOAD: '[Server] Deck Upload',
DECK_DELETE: '[Server] Deck Delete',
BACKEND_DECKS: a.backendDecks.type,
DECK_NEW_DIR: a.deckNewDir.type,
DECK_DEL_DIR: a.deckDelDir.type,
DECK_UPLOAD: a.deckUpload.type,
DECK_DELETE: a.deckDelete.type,
// User games
GAMES_OF_USER: '[Server] Games Of User',
GAMES_OF_USER: a.gamesOfUser.type,
} as const;

View file

@ -1,31 +1,117 @@
import type {
Event_RoomSay,
GameEventContext,
ServerInfo_Arrow,
ServerInfo_Card,
ServerInfo_ChatMessage,
ServerInfo_Counter,
ServerInfo_Game,
ServerInfo_PlayerProperties,
ServerInfo_Room,
ServerInfo_User,
} from '@app/generated';
import { WebSocketConnectReason } from './server';
// ── Domain model types (proto types extended with client-side fields) ─────────
export type Game = ServerInfo_Game & {
gameType: string;
};
// ── Domain model types (composition: raw proto + client-side fields) ──────────
//
// `info` holds the proto snapshot verbatim. Normalized/client-only fields
// live as siblings. For `Room`, the repeated collections on `info`
// (gameList, userList, gametypeList) are the *wire snapshot* from the last
// full update — they become stale after subsequent events. Always read from
// the normalized `games`, `users`, and `gametypeMap` fields.
export interface GametypeMap { [index: number]: string }
export type Room = ServerInfo_Room & {
/** Room directory listing — composition of raw proto with normalized collections. */
export interface Room {
info: ServerInfo_Room;
gametypeMap: GametypeMap;
gameList: Game[];
/** Server-determined display order from the UPDATE_ROOMS sequence. */
order: number;
};
games: { [gameId: number]: Game };
users: { [userName: string]: ServerInfo_User };
}
/** Room directory game listing — composition of raw proto with cached gameType. */
export interface Game {
info: ServerInfo_Game;
/** Cached display string resolved from the owning room's gametypeMap at ingest. */
gameType: string;
}
export type Message = Event_RoomSay & {
timeReceived: number;
};
// ── Active game runtime state (game slice) ───────────────────────────────────
//
// Composition pattern: the raw proto from Event_GameJoined is stored verbatim
// on `info`. Fields that evolve via in-game events live at the top level.
//
// Convention: `info` is the wire snapshot taken at join time. Fields with a
// proto twin (e.g. `started`) diverge after the first event update — always
// read the top-level field for "current value"; `info.*` is the initial
// server snapshot only.
export interface GameEntry {
info: ServerInfo_Game;
// From the Event_GameJoined wrapper (not on ServerInfo_Game itself).
hostId: number;
localPlayerId: number;
spectator: boolean;
judge: boolean;
resuming: boolean;
// Client-tracked runtime state, updated by game events.
started: boolean;
activePlayerId: number;
activePhase: number;
secondsElapsed: number;
reversed: boolean;
players: { [playerId: number]: PlayerEntry };
messages: GameMessage[];
}
/** Normalized from ServerInfo_Player — keyed collections for O(1) lookup. */
export interface PlayerEntry {
properties: ServerInfo_PlayerProperties;
deckList: string;
/** Zones keyed by zone name (e.g. "hand", "deck", "table"). */
zones: { [zoneName: string]: ZoneEntry };
/** Player-level counters (e.g. life) keyed by counter id. */
counters: { [counterId: number]: ServerInfo_Counter };
/** Arrows keyed by arrow id. */
arrows: { [arrowId: number]: ServerInfo_Arrow };
}
/**
* Normalized from ServerInfo_Zone cards indexed by id for O(1) mutation,
* with `order` preserving display sequence. Iterate via `order.map(id => byId[id])`.
*/
export interface ZoneEntry {
name: string;
/** ZoneType enum value (0=Private, 1=Public, 2=Hidden). */
type: number;
withCoords: boolean;
/** Authoritative card count. For hidden zones this may exceed `order.length`. */
cardCount: number;
/** Card ids in display order. */
order: number[];
/** Card lookup by id. */
byId: { [cardId: number]: ServerInfo_Card };
alwaysRevealTopCard: boolean;
alwaysLookAtTopCard: boolean;
}
export interface GameMessage {
playerId: number;
message: string;
timeReceived: number;
}
/**
* Passed to every game event handler alongside the event payload.
* Contains per-container metadata from GameEventContainer.

View file

@ -9,7 +9,8 @@ export interface SortBy<T extends string = string> {
}
export enum GameSortField {
START_TIME = 'startTime'
// Nested under `info` because Enriched.Game is a composition type.
START_TIME = 'info.startTime'
}
export enum UserSortField {

View file

@ -27,15 +27,6 @@ vi.mock('./services/ProtobufService', () => ({
}),
}));
vi.mock('./persistence', () => ({
RoomPersistence: { clearStore: vi.fn() },
SessionPersistence: { clearStore: vi.fn(), initialized: vi.fn(), connectionAttempted: vi.fn() },
}));
vi.mock('@app/store', () => ({
GameDispatch: { clearStore: vi.fn() },
}));
vi.mock('./commands/session', () => ({
ping: vi.fn(),
}));
@ -43,19 +34,49 @@ vi.mock('./commands/session', () => ({
import { WebClient } from './WebClient';
import { WebSocketService } from './services/WebSocketService';
import { ProtobufService } from './services/ProtobufService';
import { RoomPersistence, SessionPersistence } from './persistence';
import { ping } from './commands/session';
import { App, Enriched } from '@app/types';
import { Subject } from 'rxjs';
import { Mock } from 'vitest';
import { SocketTransport } from './services/ProtobufService';
import { WebSocketServiceConfig } from './services/WebSocketService';
import type { IWebClientResponse, IWebClientRequest } from './interfaces';
function makeMockResponse(): IWebClientResponse {
return {
session: {
initialized: vi.fn(),
connectionAttempted: vi.fn(),
clearStore: vi.fn(),
updateStatus: vi.fn(),
},
room: { clearStore: vi.fn() },
game: { clearStore: vi.fn() },
admin: {},
moderator: {},
} as unknown as IWebClientResponse;
}
function makeMockRequest(): IWebClientRequest {
return {
authentication: {},
session: {},
rooms: {},
admin: {},
moderator: {},
} as unknown as IWebClientRequest;
}
describe('WebClient', () => {
let client: WebClient;
let mockResponse: IWebClientResponse;
let mockRequest: IWebClientRequest;
let messageSubject: Subject<MessageEvent>;
beforeEach(() => {
// Reset the singleton so each test starts fresh.
(WebClient as unknown as { _instance: WebClient | null })._instance = null;
(ProtobufService as Mock).mockImplementation(function ProtobufServiceImpl(options: SocketTransport) {
captured.pbOptions = options;
return {
@ -75,32 +96,55 @@ describe('WebClient', () => {
checkReadyState: vi.fn().mockReturnValue(true),
};
});
// suppress console.log from constructor in non-test-env check
vi.spyOn(console, 'log').mockImplementation(() => {});
client = new WebClient();
mockResponse = makeMockResponse();
mockRequest = makeMockRequest();
client = new WebClient(mockResponse, mockRequest);
});
afterEach(() => {
vi.restoreAllMocks();
(WebClient as unknown as { _instance: WebClient | null })._instance = null;
});
describe('constructor', () => {
it('stores the response and request on the instance', () => {
expect(client.response).toBe(mockResponse);
expect(client.request).toBe(mockRequest);
});
it('subscribes socket.message$ to protobuf.handleMessageEvent', () => {
const event = { data: new ArrayBuffer(0) } as MessageEvent;
messageSubject.next(event);
expect(client.protobuf.handleMessageEvent).toHaveBeenCalledWith(event);
});
it('calls SessionPersistence.initialized', () => {
expect(SessionPersistence.initialized).toHaveBeenCalled();
it('calls response.session.initialized', () => {
expect(mockResponse.session.initialized).toHaveBeenCalled();
});
it('sets WebClient.instance to the constructed instance', () => {
expect(WebClient.instance).toBe(client);
});
it('throws when instantiated more than once', () => {
expect(() => new WebClient(makeMockResponse(), makeMockRequest())).toThrow(/singleton/);
});
});
describe('static instance accessor', () => {
it('throws when accessed before construction', () => {
(WebClient as unknown as { _instance: WebClient | null })._instance = null;
expect(() => WebClient.instance).toThrow(/not been initialized/);
});
});
describe('connect', () => {
it('calls SessionPersistence.connectionAttempted', () => {
it('calls response.session.connectionAttempted', () => {
const opts: Enriched.WebSocketConnectOptions = { host: 'h', port: '1', reason: App.WebSocketConnectReason.LOGIN, userName: 'u' };
client.connect(opts);
expect(SessionPersistence.connectionAttempted).toHaveBeenCalled();
expect(mockResponse.session.connectionAttempted).toHaveBeenCalled();
});
it('stores options and calls socket.connect', () => {
@ -132,35 +176,38 @@ describe('WebClient', () => {
expect(client.status).toBe(App.StatusEnum.CONNECTED);
});
it('calls protobuf.resetCommands and clears stores on DISCONNECTED', () => {
it('calls protobuf.resetCommands on DISCONNECTED', () => {
client.updateStatus(App.StatusEnum.DISCONNECTED);
expect(client.protobuf.resetCommands).toHaveBeenCalled();
expect(RoomPersistence.clearStore).toHaveBeenCalled();
expect(SessionPersistence.clearStore).toHaveBeenCalled();
});
it('does not clear stores when status is not DISCONNECTED', () => {
it('does not reset protobuf when status is not DISCONNECTED', () => {
client.updateStatus(App.StatusEnum.CONNECTED);
expect(client.protobuf.resetCommands).not.toHaveBeenCalled();
expect(RoomPersistence.clearStore).not.toHaveBeenCalled();
});
});
describe('constructor closures', () => {
it('keepAliveFn calls ping with the callback', () => {
const cb = vi.fn();
captured.wsOptions.keepAliveFn(cb);
captured.wsOptions!.keepAliveFn(cb);
expect(ping).toHaveBeenCalledWith(cb);
});
it('onStatusChange routes to response.session.updateStatus and updates own status', () => {
captured.wsOptions!.onStatusChange(App.StatusEnum.CONNECTED, 'Connected');
expect(mockResponse.session.updateStatus).toHaveBeenCalledWith(App.StatusEnum.CONNECTED, 'Connected');
expect(client.status).toBe(App.StatusEnum.CONNECTED);
});
it('send closure delegates to socket.send', () => {
const data = new Uint8Array([1, 2, 3]);
captured.pbOptions.send(data);
captured.pbOptions!.send(data);
expect(client.socket.send).toHaveBeenCalledWith(data);
});
it('isOpen closure delegates to socket.checkReadyState', () => {
const result = captured.pbOptions.isOpen();
const result = captured.pbOptions!.isOpen();
expect(client.socket.checkReadyState).toHaveBeenCalledWith(WebSocket.OPEN);
expect(result).toBe(true);
});

View file

@ -3,20 +3,43 @@ import { App, Enriched } from '@app/types';
import { ProtobufService } from './services/ProtobufService';
import { WebSocketService } from './services/WebSocketService';
import { ping } from './commands/session';
import { GameDispatch } from '@app/store';
import { RoomPersistence, SessionPersistence } from './persistence';
import { IWebClientResponse, IWebClientRequest } from './interfaces';
export class WebClient {
private static _instance: WebClient | null = null;
public static get instance(): WebClient {
if (!WebClient._instance) {
throw new Error(
'WebClient has not been initialized. Instantiate it via `new WebClient(response, request)` before accessing `WebClient.instance`.'
);
}
return WebClient._instance;
}
public socket: WebSocketService;
public protobuf: ProtobufService;
public response: IWebClientResponse;
public request: IWebClientRequest;
public options: Enriched.WebSocketConnectOptions | null = null;
public status: App.StatusEnum;
constructor() {
constructor(response: IWebClientResponse, request: IWebClientRequest) {
if (WebClient._instance) {
throw new Error('WebClient is a singleton and has already been initialized.');
}
this.response = response;
this.request = request;
this.socket = new WebSocketService({
keepAliveFn: (cb) => ping(cb),
response,
onStatusChange: (status, description) => {
this.response.session.updateStatus(status, description);
this.updateStatus(status);
},
});
this.protobuf = new ProtobufService({
@ -28,7 +51,9 @@ export class WebClient {
this.protobuf.handleMessageEvent(message);
});
SessionPersistence.initialized();
WebClient._instance = this;
this.response.session.initialized();
if (import.meta.env.MODE !== 'test') {
console.log(this);
@ -36,7 +61,7 @@ export class WebClient {
}
public connect(options: Enriched.WebSocketConnectOptions) {
SessionPersistence.connectionAttempted();
this.response.session.connectionAttempted();
this.options = options;
this.socket.connect(options);
}
@ -54,17 +79,6 @@ export class WebClient {
if (status === App.StatusEnum.DISCONNECTED) {
this.protobuf.resetCommands();
this.clearStores();
}
}
private clearStores() {
GameDispatch.clearStore();
RoomPersistence.clearStore();
SessionPersistence.clearStore();
}
}
const webClient = new WebClient();
export default webClient;

View file

@ -1,16 +1,15 @@
/**
* Shared mock shape factories for session command specs.
*
* Usage inside vi.mock() factory callbacks (require is used because
* vi.mock() is hoisted above imports):
* Usage inside vi.mock() factory callbacks:
*
* vi.mock('../../WebClient', () => {
* const { makeWebClientMock } = require('../../__mocks__/sessionCommandMocks');
* return { __esModule: true, default: makeWebClientMock() };
* vi.mock('../../WebClient', async () => {
* const { makeWebClientMock } = await import('../../__mocks__/sessionCommandMocks');
* return { WebClient: { instance: makeWebClientMock() } };
* });
*/
/** Superset WebClient mock — covers all properties used across both session spec files. */
/** Superset WebClient instance mock — covers all properties used across both session spec files. */
export function makeWebClientMock() {
return {
connect: vi.fn(),
@ -21,6 +20,17 @@ export function makeWebClientMock() {
status: 0,
protobuf: {
sendSessionCommand: vi.fn(),
sendRoomCommand: vi.fn(),
sendGameCommand: vi.fn(),
sendAdminCommand: vi.fn(),
sendModeratorCommand: vi.fn(),
},
response: {
session: makeSessionPersistenceMock(),
room: { joinRoom: vi.fn() },
game: {},
admin: {},
moderator: {},
},
};
}

View file

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

View file

@ -1,20 +1,21 @@
vi.mock('../../WebClient', () => ({
__esModule: true,
default: { protobuf: { sendAdminCommand: vi.fn() } },
}));
vi.mock('../../persistence', () => ({
AdminPersistence: {
adjustMod: vi.fn(),
reloadConfig: vi.fn(),
shutdownServer: vi.fn(),
updateServerMessage: vi.fn(),
WebClient: {
instance: {
protobuf: { sendAdminCommand: vi.fn() },
response: {
admin: {
adjustMod: vi.fn(),
reloadConfig: vi.fn(),
shutdownServer: vi.fn(),
updateServerMessage: vi.fn(),
},
},
},
},
}));
import { makeCallbackHelpers } from '../../__mocks__/callbackHelpers';
import webClient from '../../WebClient';
import { AdminPersistence } from '../../persistence';
import { WebClient } from '../../WebClient';
import { adjustMod } from './adjustMod';
import { reloadConfig } from './reloadConfig';
import { shutdownServer } from './shutdownServer';
@ -23,7 +24,7 @@ import { updateServerMessage } from './updateServerMessage';
import { Mock } from 'vitest';
const { invokeOnSuccess } = makeCallbackHelpers(
webClient.protobuf.sendAdminCommand as Mock,
WebClient.instance.protobuf.sendAdminCommand as Mock,
2
);
@ -34,13 +35,13 @@ describe('adjustMod', () => {
it('calls sendAdminCommand with Command_AdjustMod', () => {
adjustMod('alice', true, false);
expect(webClient.protobuf.sendAdminCommand).toHaveBeenCalledWith(expect.any(Object), expect.any(Object), expect.any(Object));
expect(WebClient.instance.protobuf.sendAdminCommand).toHaveBeenCalledWith(expect.any(Object), expect.any(Object), expect.any(Object));
});
it('onSuccess calls AdminPersistence.adjustMod', () => {
it('onSuccess calls response.admin.adjustMod', () => {
adjustMod('alice', true, false);
invokeOnSuccess();
expect(AdminPersistence.adjustMod).toHaveBeenCalledWith('alice', true, false);
expect(WebClient.instance.response.admin.adjustMod).toHaveBeenCalledWith('alice', true, false);
});
});
@ -51,13 +52,13 @@ describe('reloadConfig', () => {
it('calls sendAdminCommand with Command_ReloadConfig', () => {
reloadConfig();
expect(webClient.protobuf.sendAdminCommand).toHaveBeenCalledWith(expect.any(Object), expect.any(Object), expect.any(Object));
expect(WebClient.instance.protobuf.sendAdminCommand).toHaveBeenCalledWith(expect.any(Object), expect.any(Object), expect.any(Object));
});
it('onSuccess calls AdminPersistence.reloadConfig', () => {
it('onSuccess calls response.admin.reloadConfig', () => {
reloadConfig();
invokeOnSuccess();
expect(AdminPersistence.reloadConfig).toHaveBeenCalled();
expect(WebClient.instance.response.admin.reloadConfig).toHaveBeenCalled();
});
});
@ -68,13 +69,13 @@ describe('shutdownServer', () => {
it('calls sendAdminCommand with Command_ShutdownServer', () => {
shutdownServer('maintenance', 10);
expect(webClient.protobuf.sendAdminCommand).toHaveBeenCalledWith(expect.any(Object), expect.any(Object), expect.any(Object));
expect(WebClient.instance.protobuf.sendAdminCommand).toHaveBeenCalledWith(expect.any(Object), expect.any(Object), expect.any(Object));
});
it('onSuccess calls AdminPersistence.shutdownServer', () => {
it('onSuccess calls response.admin.shutdownServer', () => {
shutdownServer('maintenance', 10);
invokeOnSuccess();
expect(AdminPersistence.shutdownServer).toHaveBeenCalled();
expect(WebClient.instance.response.admin.shutdownServer).toHaveBeenCalled();
});
});
@ -85,12 +86,12 @@ describe('updateServerMessage', () => {
it('calls sendAdminCommand with Command_UpdateServerMessage', () => {
updateServerMessage();
expect(webClient.protobuf.sendAdminCommand).toHaveBeenCalledWith(expect.any(Object), expect.any(Object), expect.any(Object));
expect(WebClient.instance.protobuf.sendAdminCommand).toHaveBeenCalledWith(expect.any(Object), expect.any(Object), expect.any(Object));
});
it('onSuccess calls AdminPersistence.updateServerMessage', () => {
it('onSuccess calls response.admin.updateServerMessage', () => {
updateServerMessage();
invokeOnSuccess();
expect(AdminPersistence.updateServerMessage).toHaveBeenCalled();
expect(WebClient.instance.response.admin.updateServerMessage).toHaveBeenCalled();
});
});

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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