diff --git a/.vscode/settings.json b/.vscode/settings.json index 17d04a272..9f6ee7afb 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,4 +1,15 @@ { - "jest.jestCommandLine": "npx jest", - "typescript.tsdk": "./townService/node_modules/typescript/lib" + "jest.jestCommandLine": "npx jest", + "typescript.tsdk": "node_modules/typescript/lib", + "prettier.configPath": "./townService/.prettierrc.cjs", + "editor.formatOnSave": false, + // Runs Prettier, then ESLint + "editor.codeActionsOnSave": [ + "source.formatDocument", + "source.fixAll.eslint" + ], + "eslint.workingDirectories": [ + "./frontend", + "./townService", + ] } \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 000000000..6ec146d10 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,28 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "Start frontend Development Server", + "type": "npm", + "script": "dev", + "path": "frontend", + "group": { + "kind": "build", + "isDefault": true + } + }, + { + "label": "Start townService", + "type": "npm", + "script": "start", + "path": "townService", + "options": { + "cwd": "${workspaceFolder}/townService" + }, + "group": { + "kind": "build", + "isDefault": true + } + } + ] +} \ No newline at end of file diff --git a/README.md b/README.md index 2ea0b75de..e12d799c5 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,10 @@ The backend will automatically restart if you change any of the files in the `to Create a `.env` file in the `frontend` directory, with the line: `NEXT_PUBLIC_TOWNS_SERVICE_URL=http://localhost:8081` (if you deploy the towns service to another location, put that location here instead) +For ease of debugging, you might also set the environmental variable `NEXT_PUBLIC_TOWN_DEV_MODE=true`. When set to `true`, the frontend will +automatically connect to the town with the friendly name "DEBUG_TOWN" (creating one if needed), and will *not* try to connect to the Twilio API. This is useful if you want to quickly test changes to the frontend (reloading the page and re-acquiring video devices can be much slower than re-loading without Twilio). + ### Running the frontend -In the `frontend` directory, run `npm start` (again, you'll need to run `npm install` the very first time). After several moments (or minutes, depending on the speed of your machine), a browser will open with the frontend running locally. -The frontend will automatically re-compile and reload in your browser if you change any files in the `frontend/src` directory. +In the `frontend` directory, run `npm run dev` (again, you'll need to run `npm install` the very first time). After several moments (or minutes, depending on the speed of your machine), a browser will open with the frontend running locally. +The frontend will automatically re-compile and reload in your browser if you change any files in the `frontend/src` directory. \ No newline at end of file diff --git a/frontend/.eslintrc.js b/frontend/.eslintrc.js index a3eab9357..20f711f5d 100644 --- a/frontend/.eslintrc.js +++ b/frontend/.eslintrc.js @@ -39,6 +39,11 @@ module.exports = { selector: 'variable', format: ['camelCase'], }, + { + selector: 'variable', + types: ['function'], + format: ['camelCase', 'PascalCase'] + }, { selector: 'typeLike', format: ['PascalCase'], diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e0a3f05c2..57266cf62 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -82,6 +82,10 @@ "replace-in-file": "^6.3.5", "ts-jest": "^29.1.0", "typescript-eslint": "^0.0.1-alpha.0" + }, + "engines": { + "node": "18.x.x", + "npm": "9.x.x" } }, "node_modules/@actions/core": { diff --git a/frontend/package.json b/frontend/package.json index dfa9b88f1..caf6ed810 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -52,10 +52,15 @@ "typed-emitter": "^2.1.0", "typescript": "^4.9.5" }, + "engines": { + "node": "18.x.x", + "npm": "9.x.x" + }, "scripts": { "prestart": "npm run client", "start": "next start", "build": "next build", + "export": "next export", "dev": "next dev", "test": "cross-env DEBUG_PRINT_LIMIT=0 jest", "test-watch": "jest --watch", diff --git a/frontend/pages/_app.tsx b/frontend/pages/_app.tsx index 930965d90..d6bf5cad8 100644 --- a/frontend/pages/_app.tsx +++ b/frontend/pages/_app.tsx @@ -1,5 +1,5 @@ -import React from 'react'; import './App.css'; +import React from 'react'; import dynamic from 'next/dynamic'; //eslint-disable-next-line @typescript-eslint/naming-convention diff --git a/frontend/pages/index.tsx b/frontend/pages/index.tsx new file mode 100644 index 000000000..4a0d81b19 --- /dev/null +++ b/frontend/pages/index.tsx @@ -0,0 +1,12 @@ +import React from 'react'; + +import dynamic from 'next/dynamic'; + +//eslint-disable-next-line @typescript-eslint/naming-convention +const DynamicComponentWithNoSSR = dynamic(() => import('../src/App'), { ssr: false }); + +function NextApp() { + return ; +} + +export default NextApp; diff --git a/frontend/public/assets/tilemaps/indoors.json b/frontend/public/assets/tilemaps/indoors.json index 8f356f940..ec189ce77 100644 --- a/frontend/public/assets/tilemaps/indoors.json +++ b/frontend/public/assets/tilemaps/indoors.json @@ -34,23 +34,23 @@ 0, 0, 0, 0, 0, 0, 0, 0, 0, 3535, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 0, 0, 0, 0, 3535, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 0, 0, 0, 3535, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 0, 0, 3536, 3535, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 0, 0, 0, 0, 3536, 4205, 4205, 4205, 4205, 4208, 4209, 4205, 4205, 4205, 4208, 4209, 4205, 4205, 4205, 4208, 4209, 0, 0, 0, 0, 3227, 3228, 3228, 3228, 3228, 3228, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 3535, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 0, 0, 0, 0, 3535, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 0, 0, 0, 3535, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 0, 0, 3536, 3535, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 0, 0, 0, 0, 3536, 4205, 4205, 4205, 4205, 4205, 4205, 4205, 4205, 4205, 4205, 4205, 4205, 4205, 4205, 4205, 0, 0, 0, 0, 0, 3227, 3228, 3228, 3228, 3228, 3228, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 0, 0, 0, 0, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 0, 0, 0, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 0, 0, 0, 0, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 0, 0, 0, 0, 0, 3227, 3228, 3228, 3228, 3228, 3228, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 3536, 3536, 0, 0, 0, 0, 3536, 3536, 0, 0, 0, 0, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 0, 0, 0, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 0, 0, 0, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 0, 0, 0, 0, 3536, 3536, 3536, 4205, 4205, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 4205, 4205, 3536, 3536, 0, 0, 0, 0, 0, 3227, 3228, 3228, 3228, 3228, 3228, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 3536, 3536, 0, 0, 0, 0, 3536, 3536, 0, 0, 0, 0, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 0, 0, 0, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 0, 0, 0, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 0, 0, 0, 0, 3536, 3536, 3536, 4205, 4205, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 4205, 4205, 3536, 3536, 0, 0, 0, 0, 0, 3227, 3228, 3228, 3228, 3228, 3228, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 3460, 3460, 3460, 3536, 3536, 3460, 3460, 3460, 3460, 3536, 3536, 3460, 3460, 3460, 3460, 3460, 3536, 3536, 3460, 3460, 3460, 3460, 3536, 3536, 3460, 3460, 3460, 3460, 3460, 3536, 3460, 3460, 3460, 3460, 3460, 3536, 3536, 3460, 3460, 3460, 3460, 3536, 3536, 3460, 3460, 3460, 3460, 3536, 3536, 3460, 3536, 3536, 3536, 3460, 3536, 3536, 3460, 3460, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 0, 0, 0, 3227, 3228, 3228, 3228, 3228, 3228, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 3535, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 0, 0, 0, 3227, 3228, 3228, 3228, 3228, 3228, 0, 0, 0, 0, 0, 1947, 1948, 1948, 1948, 1948, 1948, 1948, 1948, 1948, 1948, 1948, 1948, 1948, 1948, 1948, 1948, 1948, 1948, 1948, 1948, 1948, 1948, 1948, 1948, 2101, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 3536, 3536, 0, 0, 0, 0, 3536, 3536, 0, 0, 0, 0, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 0, 0, 0, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 0, 0, 0, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 0, 0, 0, 0, 3536, 3536, 3536, 4205, 4205, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 4205, 4205, 3536, 3536, 0, 0, 0, 0, 0, 3227, 3228, 3228, 3228, 3228, 3228, 0, 0, 0, 0, 0, 1947, 1948, 1948, 1948, 1948, 1948, 1948, 1948, 1948, 1948, 1948, 1948, 1948, 1948, 1948, 1948, 1948, 1948, 1948, 1948, 1948, 1948, 1948, 1948, 2101, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 3536, 3536, 0, 0, 0, 0, 3536, 3536, 0, 0, 0, 0, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 0, 0, 0, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 0, 0, 0, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 0, 0, 0, 0, 3536, 3536, 3536, 4205, 4205, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 4205, 4205, 3536, 3536, 0, 0, 0, 0, 0, 3227, 3228, 3228, 3228, 3228, 3228, 0, 0, 0, 0, 0, 2023, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 3460, 3460, 3460, 3536, 3536, 3460, 3460, 3460, 3460, 3536, 3536, 3460, 3460, 3460, 3460, 3460, 3536, 3536, 3460, 3460, 3460, 3460, 3536, 3536, 3460, 3460, 3460, 3460, 3460, 3536, 3460, 3460, 3460, 3460, 3460, 3536, 3536, 3460, 3460, 3460, 3460, 3536, 3536, 3460, 3460, 3460, 3460, 3536, 3536, 3460, 3536, 3536, 3536, 3460, 3536, 3536, 3460, 3460, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 0, 0, 0, 3227, 3228, 3228, 3228, 3228, 3228, 0, 0, 0, 0, 0, 2023, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 3535, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 0, 0, 0, 3227, 3228, 3228, 3228, 3228, 3228, 0, 0, 0, 0, 0, 2023, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 3535, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 0, 0, 0, 3227, 3228, 3228, 3228, 3228, 3228, 0, 0, 0, 0, 0, 2023, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 3535, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3228, 3228, 3228, 3228, 3228, 3228, 3228, 3228, 3228, 0, 0, 0, 0, 0, 2023, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 3535, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3228, 3228, 3228, 3228, 3228, 3228, 3228, 3228, 3228, 0, 0, 0, 0, 0, 2023, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 3535, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3228, 3228, 3228, 3228, 3228, 3228, 3228, 3228, 3228, 0, 0, 0, 0, 0, 2023, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 3535, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3228, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2023, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 3535, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2023, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 3535, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3228, 3228, 3228, 3228, 3228, 3228, 3228, 3228, 3228, 0, 0, 0, 0, 0, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 3535, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3228, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 3535, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 3535, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2023, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 3535, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2023, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 1035, 1036, 1036, 1036, 1036, 1036, 1036, 1036, 1036, 1036, 1036, 2684355595, 2024, 2024, 2024, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 3535, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2023, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 1111, 1112, 1112, 1112, 1112, 1112, 1112, 1112, 1112, 2024, 1112, 2684355596, 2024, 2024, 2024, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 3535, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2023, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 1111, 1112, 1112, 2024, 1112, 1112, 1112, 1112, 1112, 1112, 1112, 2684355596, 2024, 2024, 2024, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 3535, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2023, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 1111, 1112, 1112, 1112, 1112, 1112, 1112, 1112, 1112, 1112, 1112, 2684355596, 2024, 2024, 2024, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 3535, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2023, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 1111, 1112, 1112, 1112, 1112, 1112, 1112, 1112, 1112, 1112, 1112, 2684355596, 2024, 2024, 2024, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 3535, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2023, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 1610613771, 3221226508, 3221226508, 3221226508, 3221226508, 3221226508, 3221226508, 3221226508, 3221226508, 3221226508, 3221226508, 3221226507, 2024, 2024, 2024, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 3535, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2023, 2024, 2024, 1112, 1112, 1112, 1112, 1112, 1112, 1112, 1035, 1036, 1036, 1036, 1036, 1036, 1036, 1036, 1036, 1036, 1036, 2684355595, 2024, 2024, 2024, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 3535, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2023, 2024, 2024, 1112, 1112, 1112, 1112, 1112, 1112, 1112, 1111, 1112, 1112, 1112, 1112, 1112, 1112, 1112, 1112, 2024, 1112, 2684355596, 2024, 2024, 2024, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 3535, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2023, 2024, 2024, 1112, 1112, 1112, 1112, 1112, 1112, 1112, 1111, 1112, 1112, 2024, 1112, 1112, 1112, 1112, 1112, 1112, 1112, 2684355596, 2024, 2024, 2024, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 3535, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2023, 2024, 2024, 1112, 1112, 1112, 1112, 1112, 1112, 1112, 1111, 1112, 1112, 1112, 1112, 1112, 1112, 1112, 1112, 1112, 1112, 2684355596, 2024, 2024, 2024, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 3535, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2023, 2024, 2024, 1112, 1112, 1112, 1112, 1112, 1112, 1112, 1111, 1112, 1112, 1112, 1112, 1112, 1112, 1112, 1112, 1112, 1112, 2684355596, 2024, 2024, 2024, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 3535, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2023, 2024, 2024, 1112, 1112, 1112, 1112, 1112, 1112, 1112, 1610613771, 3221226508, 3221226508, 3221226508, 3221226508, 3221226508, 3221226508, 3221226508, 3221226508, 3221226508, 3221226508, 3221226507, 2024, 2024, 2024, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 3535, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 3536, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2023, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, @@ -88,15 +88,15 @@ 0, 0, 0, 0, 0, 0, 0, 0, 4546, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4551, 0, 0, 4319, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4551, 0, 4319, 8814, 8814, 8814, 8814, 8814, 8814, 8814, 8814, 8814, 8814, 4551, 0, 0, 4319, 0, 0, 0, 0, 0, 0, 0, 0, 4322, 0, 0, 0, 8043, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 8046, 0, 0, 0, 4311, 0, 0, 0, 0, 0, 0, 4467, 4541, 4541, 4541, 4541, 4541, 4541, 4541, 4541, 4541, 4541, 4541, 4541, 4541, 4541, 4541, 4541, 4541, 4542, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4546, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4551, 0, 0, 4319, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4551, 0, 4319, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4551, 0, 0, 4319, 0, 0, 0, 0, 0, 0, 0, 0, 4322, 0, 0, 0, 8043, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 8046, 0, 0, 0, 4311, 0, 0, 0, 0, 0, 0, 4314, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4546, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4551, 0, 0, 4319, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4551, 0, 4319, 8814, 8814, 8814, 8814, 8814, 8814, 8814, 8814, 8814, 8814, 4551, 0, 0, 4319, 0, 0, 0, 0, 0, 0, 0, 0, 4322, 0, 0, 0, 8043, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 8046, 0, 0, 0, 4311, 0, 0, 0, 0, 0, 0, 4314, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 4546, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4322, 0, 0, 4319, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4551, 0, 4319, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4551, 0, 0, 4319, 0, 0, 0, 0, 0, 0, 0, 0, 4322, 0, 0, 0, 8043, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 8046, 0, 0, 0, 4311, 0, 0, 0, 0, 0, 0, 4314, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 4320, 4320, 4320, 4320, 0, 0, 4320, 4320, 4320, 4320, 0, 0, 4320, 4320, 4320, 4320, 4320, 0, 0, 4320, 4320, 4320, 4320, 0, 0, 4320, 4320, 4320, 4320, 4320, 0, 0, 4320, 4320, 4320, 4320, 8797, 0, 4393, 4320, 4320, 4320, 0, 0, 4320, 0, 0, 4320, 0, 0, 4321, 4320, 4320, 4320, 4320, 4320, 4320, 0, 0, 4320, 4320, 4320, 4320, 4320, 4320, 4320, 0, 0, 4320, 4320, 4321, 4320, 4170, 0, 4311, 0, 0, 0, 0, 0, 0, 4314, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 4473, 4473, 0, 0, 4473, 4473, 4473, 4473, 0, 0, 4473, 4473, 4473, 4473, 4473, 0, 0, 4473, 4473, 4473, 4473, 0, 0, 4473, 4473, 4473, 4473, 4473, 0, 8797, 4473, 4473, 4473, 4473, 8797, 0, 4473, 4473, 4473, 4473, 0, 0, 4473, 4473, 4473, 4473, 0, 0, 4473, 4473, 4473, 4473, 4473, 4473, 4473, 0, 0, 4473, 4473, 4473, 4473, 4473, 4473, 4473, 0, 0, 4473, 4473, 4473, 4473, 4246, 0, 4311, 0, 0, 0, 0, 0, 0, 4314, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 4397, 4397, 0, 0, 4397, 4397, 4397, 4397, 0, 0, 4397, 4397, 4397, 4397, 4397, 0, 0, 4397, 4397, 4397, 4397, 0, 0, 4397, 4397, 4397, 4397, 4397, 0, 1073750605, 4397, 4397, 4397, 4397, 1073750605, 0, 4397, 4397, 4397, 4397, 0, 0, 4397, 4397, 4397, 4397, 0, 0, 4397, 4396, 4396, 4396, 4397, 4397, 4397, 0, 0, 4397, 4397, 4397, 4397, 4397, 4397, 4397, 0, 0, 4397, 4397, 4397, 4396, 4246, 0, 4311, 0, 0, 0, 0, 0, 0, 4314, 0, 0, 0, 5223, 5376, 5376, 5376, 5376, 5376, 5376, 5376, 5376, 5376, 5376, 5376, 5376, 5376, 5376, 5376, 5376, 5376, 5376, 5376, 5376, 5376, 5376, 5376, 5376, 5376, 5226, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1055, 1056, 0, 0, 0, 0, 1055, 1056, 0, 0, 0, 0, 0, 1055, 1056, 0, 0, 0, 0, 1055, 1056, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4551, 0, 4311, 0, 0, 0, 0, 0, 0, 4314, 0, 0, 0, 5299, 5452, 5452, 5452, 5452, 5452, 5452, 5452, 5452, 5452, 5452, 5452, 5452, 5452, 5452, 5452, 5452, 5452, 5452, 5452, 5452, 5452, 5452, 5452, 5452, 5452, 5302, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4398, 4312, 4387, 0, 0, 0, 0, 0, 0, 4314, 0, 0, 0, 5375, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 10331, 3758097427, 1610613780, 10328, 0, 0, 5378, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4474, 4388, 4463, 0, 0, 0, 0, 0, 0, 4314, 0, 0, 0, 5375, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 10331, 3758097426, 1610613778, 10328, 0, 0, 5378, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4314, 0, 0, 0, 5899, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 10331, 3758097426, 1610613778, 10328, 0, 0, 5378, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4314, 0, 0, 0, 5899, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 10347, 2684355604, 1610613779, 10344, 0, 0, 5378, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 4546, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4322, 0, 0, 4319, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4551, 0, 4319, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4551, 0, 0, 4319, 0, 0, 0, 0, 0, 0, 0, 0, 4322, 0, 0, 0, 8043, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 8046, 0, 0, 0, 4311, 0, 0, 0, 0, 0, 0, 4314, 0, 0, 0, 5223, 5376, 5376, 5376, 5376, 5376, 5376, 5376, 5376, 5376, 5376, 5376, 5376, 5376, 5376, 5376, 5376, 5376, 5376, 5376, 5376, 5376, 5376, 5376, 5376, 5376, 5226, 0, 0, + 0, 0, 0, 0, 0, 0, 4320, 4320, 4320, 4320, 0, 0, 4320, 4320, 4320, 4320, 0, 0, 4320, 4320, 4320, 4320, 4320, 0, 0, 4320, 4320, 4320, 4320, 0, 0, 4320, 4320, 4320, 4320, 4320, 0, 0, 4320, 4320, 4320, 4320, 8797, 0, 4393, 4320, 4320, 4320, 0, 0, 4320, 0, 0, 4320, 0, 0, 4321, 4320, 4320, 4320, 4320, 4320, 4320, 0, 0, 4320, 4320, 4320, 4320, 4320, 4320, 4320, 0, 0, 4320, 4320, 4321, 4320, 4170, 0, 4311, 0, 0, 0, 0, 0, 0, 4314, 0, 0, 0, 5299, 5452, 5452, 5452, 5452, 5452, 5452, 5452, 5452, 5452, 5452, 5452, 5452, 5452, 5452, 5452, 5452, 5452, 5452, 5452, 5452, 5452, 5452, 5452, 5452, 5452, 5302, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 4473, 4473, 0, 0, 4473, 4473, 4473, 4473, 0, 0, 4473, 4473, 4473, 4473, 4473, 0, 0, 4473, 4473, 4473, 4473, 0, 0, 4473, 4473, 4473, 4473, 4473, 0, 8797, 4473, 4473, 4473, 4473, 8797, 0, 4473, 4473, 4473, 4473, 0, 0, 4473, 4473, 4473, 4473, 0, 0, 4473, 4473, 4473, 4473, 4473, 4473, 4473, 0, 0, 4473, 4473, 4473, 4473, 4473, 4473, 4473, 0, 0, 4473, 4473, 4473, 4473, 4246, 0, 4311, 0, 0, 0, 0, 0, 0, 4314, 0, 0, 0, 5375, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 10331, 3758097427, 1610613780, 10328, 0, 0, 5378, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 4397, 4397, 0, 0, 4397, 4397, 4397, 4397, 0, 0, 4397, 4397, 4397, 4397, 4397, 0, 0, 4397, 4397, 4397, 4397, 0, 0, 4397, 4397, 4397, 4397, 4397, 0, 1073750605, 4397, 4397, 4397, 4397, 1073750605, 0, 4397, 4397, 4397, 4397, 0, 0, 4397, 4397, 4397, 4397, 0, 0, 4397, 4396, 4396, 4396, 4397, 4397, 4397, 0, 0, 4397, 4397, 4397, 4397, 4397, 4397, 4397, 0, 0, 4397, 4397, 4397, 4396, 4246, 0, 4311, 0, 0, 0, 0, 0, 0, 4314, 0, 0, 0, 5375, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 10331, 3758097426, 1610613778, 10328, 0, 0, 5378, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1055, 1056, 0, 0, 0, 0, 1055, 1056, 0, 0, 0, 0, 0, 1055, 1056, 0, 0, 0, 0, 1055, 1056, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4551, 0, 4311, 0, 0, 0, 0, 0, 0, 4314, 0, 0, 0, 5899, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 10331, 3758097426, 1610613778, 10328, 0, 0, 5378, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4398, 4312, 4387, 0, 0, 0, 0, 0, 0, 4314, 0, 0, 0, 5899, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 10347, 2684355604, 1610613779, 10344, 0, 0, 5378, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4474, 4388, 4463, 0, 0, 0, 0, 0, 0, 4314, 0, 0, 0, 5899, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 5378, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4314, 0, 0, 0, 5899, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 5378, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4314, 0, 0, 0, 5899, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 5378, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4314, 0, 0, 0, 5899, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 5378, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4475, 4539, 4540, 4540, 4540, 4540, 4540, 4540, 4540, 4542, 0, 0, 0, 5899, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 5378, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4322, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 5899, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 5378, 0, 0, @@ -145,22 +145,22 @@ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 8797, 0, 8797, 0, 0, 8797, 0, 8797, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 8797, 0, 8797, 0, 0, 8797, 0, 8797, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 8797, 0, 8797, 0, 0, 8797, 0, 8797, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 10315, 0, 0, 10312, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 8814, 8814, 0, 0, 0, 0, 8814, 8814, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 1055, 1056, 0, 0, 0, 0, 0, 1056, 0, 0, 0, 0, 0, 1055, 0, 0, 0, 0, 0, 1055, 1056, 0, 0, 0, 0, 0, 0, 1056, 0, 0, 0, 0, 0, 1056, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 10315, 0, 0, 10312, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 1055, 1056, 0, 0, 0, 0, 0, 1056, 0, 0, 0, 0, 0, 1055, 0, 0, 0, 0, 0, 1055, 1056, 0, 0, 0, 0, 0, 0, 1056, 0, 0, 0, 0, 0, 1056, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 8568, 8568, 8568, 8568, 8568, 8569, 0, 0, 8567, 8568, 8568, 8568, 8568, 8568, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 12707, 12708, 12709, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 8584, 8584, 8584, 8584, 8584, 8585, 0, 0, 8583, 8584, 8584, 8584, 8584, 8584, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 12723, 12724, 12725, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 8568, 8568, 8568, 8568, 8568, 8569, 0, 0, 8567, 8568, 8568, 8568, 8568, 8568, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 8584, 8584, 8584, 8584, 8584, 8585, 0, 0, 8583, 8584, 8584, 8584, 8584, 8584, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 12723, 12724, 12725, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 13213, 0, 13213, 0, 0, 0, 0, 13213, 0, 13213, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 13213, 0, 13213, 0, 0, 0, 0, 13213, 0, 13213, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1801, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1649, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 13213, 0, 13213, 0, 0, 0, 0, 13213, 0, 13213, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 13213, 0, 13213, 0, 0, 0, 0, 13213, 0, 13213, 0, 0, 0, 0, 13213, 0, 13213, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 13213, 0, 13213, 0, 0, 0, 0, 13213, 0, 13213, 0, 0, 0, 0, 13213, 0, 13213, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1801, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1649, 0, 0, 0, 0, 13213, 0, 13213, 0, 0, 0, 0, 13213, 0, 13213, 0, 0, 0, 0, 13213, 0, 13213, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1877, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1649, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1649, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, @@ -200,24 +200,24 @@ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 9274, 9275, 9276, 0, 0, 0, 0, 0, 0, 0, 8527, 8528, 8742, 8529, 8530, 0, 0, 0, 0, 8813, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 8815, 0, 0, 0, 0, 0, 9602, 9603, 9604, 9605, 9606, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 9290, 9291, 9292, 0, 0, 0, 0, 0, 0, 0, 8543, 8544, 8758, 8545, 8546, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 9618, 9619, 9620, 9621, 9622, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 9306, 9307, 9308, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 8813, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 8815, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 8968, 8969, 8970, 8971, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 8984, 8985, 8986, 8987, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 4167, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 4243, 4473, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 9038, 9039, 0, 0, 0, 0, 0, 0, 0, 8989, 8990, 0, 0, 0, 0, 0, 0, 9038, 9039, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 4243, 4397, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 8562, 8564, 0, 0, 0, 0, 8562, 8564, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 9054, 9055, 0, 0, 0, 0, 0, 0, 0, 9005, 9006, 0, 0, 0, 0, 0, 0, 9054, 9055, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 10894, 0, 10893, 0, 10894, 0, 10893, 0, 10894, 0, 10893, 0, 0, 0, 0, 0, 0, 0, 0, 10713, 10714, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 4319, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 8578, 8580, 0, 0, 0, 0, 8578, 8580, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 9070, 9071, 0, 0, 0, 0, 0, 0, 0, 9021, 9022, 0, 0, 0, 0, 0, 0, 9070, 9071, 0, 0, 0, 0, 0, 9032, 9033, 9035, 9036, 0, 0, 0, 0, 0, 0, 10910, 0, 10909, 0, 10910, 0, 10909, 0, 10910, 0, 10909, 0, 0, 10432, 10433, 10434, 0, 10314, 0, 10729, 10730, 10312, 10313, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 4319, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 9048, 9049, 9051, 9052, 0, 0, 0, 0, 0, 0, 10926, 0, 10925, 0, 10926, 0, 10925, 0, 10926, 0, 10925, 0, 0, 10448, 10449, 10450, 0, 10330, 0, 0, 0, 0, 10329, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 4319, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 9064, 9065, 9067, 9068, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 12691, 12692, 12693, 0, 10464, 10465, 10466, 0, 10330, 0, 0, 0, 0, 10329, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 4319, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 8567, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 9064, 9065, 9067, 9068, 0, 0, 0, 0, 0, 10927, 0, 0, 0, 0, 0, 10641, 10642, 10643, 10644, 0, 0, 0, 0, 0, 0, 0, 0, 10330, 0, 0, 0, 0, 10329, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 4319, 0, 0, 0, 0, 0, 0, 0, 9261, 9262, 9263, 9264, 9265, 9266, 9267, 9268, 9269, 9270, 0, 0, 0, 0, 0, 0, 9261, 9262, 9263, 9264, 9265, 9266, 9267, 9268, 9269, 9270, 0, 0, 0, 0, 0, 9261, 9262, 9263, 9264, 9265, 9266, 9267, 9268, 9269, 9270, 0, 0, 0, 0, 0, 8583, 0, 12701, 12702, 12702, 12702, 12703, 0, 0, 0, 12701, 12702, 12702, 12702, 12703, 0, 0, 0, 0, 0, 0, 0, 0, 9064, 9065, 9067, 9068, 0, 0, 0, 0, 0, 0, 0, 0, 8360, 8361, 0, 10657, 10658, 10659, 10660, 0, 0, 0, 0, 0, 0, 0, 0, 10346, 0, 0, 0, 0, 10345, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 4319, 0, 0, 0, 0, 0, 0, 0, 9277, 9278, 9279, 9280, 9281, 9282, 9283, 9284, 9285, 9286, 0, 0, 0, 0, 0, 0, 9277, 9278, 9279, 9280, 9281, 9282, 9283, 9284, 9285, 9286, 0, 0, 0, 0, 0, 9277, 9278, 9279, 9280, 9281, 9282, 9283, 9284, 9285, 9286, 0, 0, 0, 0, 0, 8587, 0, 12717, 12718, 12718, 12718, 12719, 0, 0, 0, 12717, 12718, 12718, 12718, 12719, 0, 0, 0, 0, 0, 0, 0, 0, 9080, 9081, 9083, 9084, 0, 0, 0, 0, 0, 10913, 0, 0, 8376, 8377, 0, 10673, 10674, 10675, 10676, 0, 0, 0, 0, 0, 0, 0, 0, 10332, 0, 0, 0, 0, 10332, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 8984, 8985, 8986, 8987, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 10893, 0, 10894, 0, 10893, 0, 0, 0, 10997, 10998, 10999, 0, 0, 10713, 10714, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 4167, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 4321, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 10909, 0, 10910, 0, 10909, 0, 11011, 11012, 11013, 11014, 11015, 10314, 0, 10729, 10730, 10312, 10313, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 4243, 4473, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 9038, 9039, 0, 0, 0, 0, 0, 0, 0, 8989, 8990, 0, 0, 0, 0, 0, 0, 9038, 9039, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 13985, 0, 13986, 0, 0, 0, 0, 10925, 0, 10926, 0, 10925, 0, 11027, 11028, 11029, 11030, 11031, 10330, 0, 0, 0, 0, 10329, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 4243, 4397, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 8562, 8564, 0, 0, 0, 0, 8562, 8564, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 9054, 9055, 0, 0, 0, 0, 0, 0, 0, 9005, 9006, 0, 0, 0, 0, 0, 0, 9054, 9055, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 14001, 13890, 14002, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 10330, 0, 0, 0, 0, 10329, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 4319, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 8578, 8580, 0, 0, 0, 0, 8578, 8580, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 9070, 9071, 0, 0, 0, 0, 0, 0, 0, 9021, 9022, 0, 0, 0, 0, 0, 0, 9070, 9071, 0, 0, 0, 0, 0, 9032, 9033, 9035, 9036, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 10330, 0, 0, 0, 0, 10329, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 4319, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 9048, 9049, 9051, 9052, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 10346, 0, 0, 0, 0, 10345, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 4319, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 9064, 9065, 9067, 9068, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 10332, 0, 0, 0, 0, 10332, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 4319, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 8567, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 9064, 9065, 9067, 9068, 0, 0, 0, 0, 0, 13985, 0, 13986, 0, 0, 0, 10641, 10642, 10643, 10644, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 4319, 0, 0, 0, 0, 0, 0, 0, 9261, 9262, 9263, 9264, 9265, 9266, 9267, 9268, 9269, 9270, 0, 0, 0, 0, 0, 0, 9261, 9262, 9263, 9264, 9265, 9266, 9267, 9268, 9269, 9270, 0, 0, 0, 0, 0, 9261, 9262, 9263, 9264, 9265, 9266, 9267, 9268, 9269, 9270, 0, 0, 0, 0, 0, 8583, 0, 12701, 12702, 12702, 12702, 12703, 0, 0, 0, 12701, 12702, 12702, 12702, 12703, 0, 0, 0, 0, 0, 0, 0, 0, 9064, 9065, 9067, 9068, 0, 0, 0, 0, 0, 14001, 13890, 14002, 0, 0, 0, 10657, 10658, 10659, 10660, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 10899, 10900, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 4319, 0, 0, 0, 0, 0, 0, 0, 9277, 9278, 9279, 9280, 9281, 9282, 9283, 9284, 9285, 9286, 0, 0, 0, 0, 0, 0, 9277, 9278, 9279, 9280, 9281, 9282, 9283, 9284, 9285, 9286, 0, 0, 0, 0, 0, 9277, 9278, 9279, 9280, 9281, 9282, 9283, 9284, 9285, 9286, 0, 0, 0, 0, 0, 8587, 0, 12717, 12718, 12718, 12718, 12719, 0, 0, 0, 12717, 12718, 12718, 12718, 12719, 0, 0, 0, 0, 0, 0, 0, 0, 9080, 9081, 9083, 9084, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 10673, 10674, 10675, 10676, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 10915, 10916, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4319, 0, 0, 0, 0, 0, 0, 0, 9293, 9294, 9295, 9296, 9297, 9298, 9299, 9300, 9301, 9302, 0, 0, 0, 0, 0, 0, 9293, 9294, 9295, 9296, 9297, 9298, 9299, 9300, 9301, 9302, 0, 0, 0, 0, 0, 9293, 9294, 9295, 9296, 9297, 9298, 9299, 9300, 9301, 9302, 0, 0, 0, 0, 0, 8587, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 4319, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 8587, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 10803, 10804, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 4319, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 8587, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 10819, 10820, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 4319, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 8587, 0, 12701, 12702, 12702, 12702, 12703, 0, 0, 0, 12701, 12702, 12702, 12702, 12703, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 10863, 10864, 0, 0, 0, 0, 0, 0, 0, 0, 0, 13283, 13284, 13285, 0, 0, 0, 0, 13283, 13284, 13285, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 4319, 10451, 10453, 10454, 0, 0, 0, 10451, 10452, 10453, 10454, 0, 0, 0, 10451, 10452, 10453, 10454, 0, 0, 0, 10451, 10452, 10453, 10454, 0, 0, 0, 0, 10451, 10452, 10453, 10454, 0, 0, 0, 0, 10451, 10452, 10453, 10454, 0, 0, 0, 0, 10451, 10452, 10453, 10454, 0, 0, 0, 0, 0, 8587, 0, 12717, 12718, 12718, 12718, 12719, 0, 0, 8567, 12717, 12718, 12718, 12718, 12719, 0, 0, 0, 1649, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1649, 10879, 10880, 0, 0, 0, 0, 0, 0, 0, 0, 0, 13267, 13268, 13269, 0, 0, 0, 0, 13267, 13268, 13269, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 4319, 10467, 10469, 10470, 0, 0, 0, 10467, 10468, 10469, 10470, 0, 0, 0, 10467, 10468, 10469, 10470, 0, 0, 0, 10467, 10468, 10469, 10470, 0, 0, 0, 0, 10467, 10468, 10469, 10470, 0, 0, 0, 0, 10467, 10468, 10469, 10470, 0, 0, 0, 0, 10467, 10468, 10469, 10470, 0, 0, 0, 0, 0, 8583, 0, 0, 0, 0, 0, 0, 0, 0, 8915, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 13267, 13268, 13269, 0, 0, 0, 0, 13267, 13268, 13269, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 4319, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 8931, 0, 0, 0, 0, 0, 0, 0, 0, 1649, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 13267, 13268, 13269, 0, 0, 0, 0, 13267, 13268, 13269, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 4319, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 13299, 13300, 13301, 0, 0, 0, 0, 13299, 13300, 13301, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 4319, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 8587, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 10899, 10900, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 4319, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 8587, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 10915, 10916, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 4319, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 8587, 0, 12701, 12702, 12702, 12702, 12703, 0, 0, 0, 12701, 12702, 12702, 12702, 12703, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 13283, 13284, 13285, 0, 0, 0, 0, 13283, 13284, 13285, 0, 0, 0, 0, 13283, 13284, 13285, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 4319, 10451, 10453, 10454, 0, 0, 0, 10451, 10452, 10453, 10454, 0, 0, 0, 10451, 10452, 10453, 10454, 0, 0, 0, 10451, 10452, 10453, 10454, 0, 0, 0, 0, 10451, 10452, 10453, 10454, 0, 0, 0, 0, 10451, 10452, 10453, 10454, 0, 0, 0, 0, 10451, 10452, 10453, 10454, 0, 0, 0, 0, 0, 8587, 0, 12717, 12718, 12718, 12718, 12719, 0, 0, 8567, 12717, 12718, 12718, 12718, 12719, 0, 0, 0, 1649, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1649, 0, 0, 0, 0, 13267, 13268, 13269, 0, 0, 0, 0, 13267, 13268, 13269, 0, 0, 0, 0, 13267, 13268, 13269, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 4319, 10467, 10469, 10470, 0, 0, 0, 10467, 10468, 10469, 10470, 0, 0, 0, 10467, 10468, 10469, 10470, 0, 0, 0, 10467, 10468, 10469, 10470, 0, 0, 0, 0, 10467, 10468, 10469, 10470, 0, 0, 0, 0, 10467, 10468, 10469, 10470, 0, 0, 0, 0, 10467, 10468, 10469, 10470, 0, 0, 0, 0, 0, 8583, 0, 0, 0, 0, 0, 0, 0, 0, 8915, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 13267, 13268, 13269, 0, 0, 0, 0, 13267, 13268, 13269, 0, 0, 0, 0, 13267, 13268, 13269, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 4319, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 8931, 0, 0, 0, 0, 0, 0, 0, 0, 1649, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 13267, 13268, 13269, 0, 0, 0, 0, 13267, 13268, 13269, 0, 0, 0, 0, 13267, 13268, 13269, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 4319, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 13299, 13300, 13301, 0, 0, 0, 0, 13299, 13300, 13301, 0, 0, 0, 0, 13299, 13300, 13301, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, @@ -259,20 +259,20 @@ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4320, 8755, 4320, 4320, 8755, 4320, 4320, 8755, 4320, 4320, 8755, 4320, 4320, 4320, 8755, 4320, 4320, 8755, 4320, 4320, 8755, 4320, 4320, 8755, 4320, 4320, 4320, 8755, 4320, 4320, 8755, 4320, 4320, 8755, 4320, 4320, 8755, 0, 0, 8755, 4320, 4320, 8755, 4320, 4320, 8755, 4320, 4320, 8755, 0, 0, 0, 0, 0, 8755, 4320, 4320, 8755, 0, 0, 0, 0, 0, 8755, 4320, 4320, 8755, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 8771, 0, 0, 8771, 8690, 8691, 8771, 0, 0, 8771, 0, 8696, 0, 8771, 0, 0, 8771, 8723, 8724, 8771, 0, 0, 8771, 0, 8695, 0, 8771, 0, 0, 8771, 8721, 8722, 8771, 0, 0, 8771, 0, 0, 8771, 0, 0, 8771, 8690, 8691, 8771, 0, 0, 8771, 0, 0, 0, 0, 11083, 8771, 0, 0, 8771, 11084, 0, 0, 0, 11083, 8771, 0, 0, 8771, 11084, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 8787, 0, 0, 8787, 8706, 8707, 8787, 0, 0, 8787, 0, 8712, 0, 8787, 0, 0, 8787, 8739, 8740, 8787, 0, 0, 8787, 0, 8711, 0, 8787, 0, 0, 8787, 8737, 8738, 8787, 0, 0, 8787, 0, 0, 8787, 0, 0, 8787, 8706, 8707, 8787, 0, 0, 8787, 0, 0, 0, 0, 11099, 8787, 0, 0, 8787, 11100, 0, 0, 0, 11099, 8787, 0, 0, 8787, 11100, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 8803, 0, 0, 8803, 0, 0, 8803, 0, 0, 8803, 0, 0, 0, 8803, 0, 0, 8803, 0, 0, 8803, 0, 0, 8803, 0, 0, 0, 8803, 0, 0, 8803, 0, 0, 8803, 0, 0, 8803, 0, 0, 8803, 0, 0, 8803, 0, 0, 8803, 0, 0, 8803, 0, 0, 0, 0, 0, 8803, 0, 0, 8803, 0, 0, 0, 0, 0, 8803, 0, 0, 8803, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 10543, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 10546, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 9092, 9111, 9100, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 10562, 0, 0, 0, 0, 10923, 10924, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 8569, 0, 0, 0, 0, 0, 0, 0, 0, 0, 9096, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 10939, 10940, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 8570, 0, 12083, 12084, 12079, 12085, 12086, 0, 0, 0, 12083, 12084, 12079, 12085, 12086, 8585, 0, 0, 0, 0, 0, 0, 0, 0, 9095, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 10655, 10656, 0, 0, 0, 0, 0, 0, 0, 0, 0, 10316, 0, 0, 0, 0, 10316, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 8787, 0, 0, 8787, 8706, 8707, 8787, 0, 0, 8787, 0, 8712, 0, 8787, 0, 0, 8787, 8739, 8740, 8787, 0, 0, 8787, 0, 8711, 0, 8787, 0, 0, 8787, 8737, 8738, 8787, 0, 0, 8787, 0, 0, 8787, 0, 0, 8787, 8706, 8707, 8787, 0, 0, 8787, 0, 0, 0, 0, 11099, 8787, 0, 0, 8787, 11100, 0, 0, 0, 11099, 8787, 0, 0, 8787, 11100, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 14858, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 10923, 10924, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 8803, 0, 0, 8803, 0, 0, 8803, 0, 0, 8803, 0, 0, 0, 8803, 0, 0, 8803, 0, 0, 8803, 0, 0, 8803, 0, 0, 0, 8803, 0, 0, 8803, 0, 0, 8803, 0, 0, 8803, 0, 0, 8803, 0, 0, 8803, 0, 0, 8803, 0, 0, 8803, 0, 0, 0, 0, 0, 8803, 0, 0, 8803, 0, 0, 0, 0, 0, 8803, 0, 0, 8803, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 10939, 10940, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 10316, 0, 0, 0, 0, 10316, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 9092, 9111, 9100, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 12691, 12692, 12693, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 8569, 0, 0, 0, 0, 0, 0, 0, 0, 0, 9096, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 10641, 10642, 10643, 10644, 0, 0, 12707, 12708, 12709, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 8570, 0, 12083, 12084, 12079, 12085, 12086, 0, 0, 0, 12083, 12084, 12079, 12085, 12086, 8585, 0, 0, 0, 0, 0, 0, 0, 0, 9095, 0, 0, 0, 0, 0, 0, 0, 0, 14324, 0, 0, 0, 0, 10657, 10655, 10656, 10660, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 8587, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 9108, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 8587, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 8587, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 8587, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 10237, 10238, 10239, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 12083, 12084, 12079, 12085, 12086, 0, 0, 0, 12083, 12084, 12079, 12085, 12086, 8587, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 10253, 10254, 10255, 0, 0, 0, 0, 0, 0, 0, 0, 13781, 0, 0, 0, 0, 0, 0, 13389, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 10284, 0, 0, 0, 0, 0, 10284, 0, 0, 0, 0, 0, 0, 10316, 0, 0, 0, 0, 0, 0, 0, 10348, 0, 0, 0, 0, 0, 0, 10611, 10316, 0, 0, 0, 0, 0, 0, 10284, 10611, 0, 0, 0, 0, 0, 0, 10348, 10610, 0, 0, 0, 0, 0, 0, 0, 8568, 8568, 8568, 8568, 8568, 8569, 0, 0, 0, 8568, 8568, 8568, 8568, 8568, 8587, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 10269, 10270, 10271, 0, 0, 0, 0, 0, 0, 0, 0, 13405, 0, 0, 0, 0, 0, 0, 13357, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 10300, 0, 0, 0, 0, 0, 10300, 0, 0, 0, 0, 0, 0, 10332, 0, 0, 0, 0, 0, 0, 0, 10364, 0, 0, 0, 0, 0, 0, 10627, 10332, 0, 0, 0, 0, 0, 0, 10300, 10627, 0, 0, 0, 0, 0, 0, 10364, 10626, 0, 0, 0, 0, 0, 0, 0, 8584, 8584, 8584, 8584, 8584, 8585, 0, 0, 0, 8584, 8584, 8584, 8584, 8584, 8585, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 13407, 0, 0, 0, 0, 0, 0, 13405, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 13358, 0, 0, 0, 0, 0, 0, 13374, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 8587, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 10237, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 12083, 12084, 12079, 12085, 12086, 0, 0, 0, 12083, 12084, 12079, 12085, 12086, 8587, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 10316, 0, 0, 0, 0, 0, 0, 13781, 0, 0, 0, 0, 0, 0, 13389, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 10284, 0, 0, 0, 0, 0, 10284, 0, 0, 0, 0, 0, 0, 10316, 0, 0, 0, 0, 0, 0, 0, 10348, 0, 0, 0, 0, 0, 0, 10611, 10316, 0, 0, 0, 0, 0, 0, 10284, 10611, 0, 0, 0, 0, 0, 0, 10348, 10610, 0, 0, 0, 0, 0, 0, 0, 8568, 8568, 8568, 8568, 8568, 8569, 0, 0, 0, 8568, 8568, 8568, 8568, 8568, 8587, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 10332, 0, 0, 0, 0, 0, 0, 13405, 0, 0, 0, 0, 0, 0, 13357, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 10300, 0, 0, 0, 0, 0, 10300, 0, 0, 0, 0, 0, 0, 10332, 0, 0, 0, 0, 0, 0, 0, 10364, 0, 0, 0, 0, 0, 0, 10627, 10332, 0, 0, 0, 0, 0, 0, 10300, 10627, 0, 0, 0, 0, 0, 0, 10364, 10626, 0, 0, 0, 0, 0, 0, 0, 8584, 8584, 8584, 8584, 8584, 8585, 0, 0, 0, 8584, 8584, 8584, 8584, 8584, 8585, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 10284, 0, 0, 0, 0, 0, 0, 13407, 0, 0, 0, 0, 0, 0, 13405, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 10300, 0, 0, 0, 0, 0, 0, 13358, 0, 0, 0, 0, 0, 0, 13374, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, @@ -317,10 +317,10 @@ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 903, 904, 0, 0, 0, 0, 903, 904, 0, 0, 0, 0, 0, 903, 904, 0, 0, 0, 0, 903, 904, 0, 0, 0, 0, 0, 903, 904, 0, 0, 0, 0, 903, 904, 0, 0, 0, 0, 903, 904, 0, 0, 0, 0, 903, 904, 0, 0, 0, 0, 0, 0, 0, 903, 904, 0, 0, 0, 0, 0, 0, 0, 903, 904, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 979, 980, 0, 0, 0, 0, 979, 980, 0, 0, 0, 0, 0, 979, 980, 0, 0, 0, 0, 979, 980, 0, 0, 0, 0, 0, 979, 980, 0, 0, 0, 0, 979, 980, 0, 0, 0, 0, 979, 980, 0, 0, 0, 0, 979, 980, 0, 0, 0, 0, 0, 0, 0, 979, 980, 0, 0, 0, 0, 0, 0, 0, 979, 980, 0, 0, 0, 0, 0, 0, 0, 0, 0, 8887, 0, 8888, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1055, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1055, 1056, 0, 0, 0, 0, 1055, 1056, 0, 0, 0, 0, 1055, 1056, 0, 0, 0, 0, 1055, 1056, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 8887, 0, 0, 0, 0, 0, 0, 10895, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 10911, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 8887, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 8571, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 10897, 0, 0, 0, 0, 0, 0, 10654, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 8887, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 8571, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 10654, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, @@ -352,19 +352,6 @@ "name":"Objects", "objects":[ { - "type":"", - "height":0, - "id":1, - "name":"Spawn Point", - "point":true, - "rotation":0, - "visible":true, - "width":0, - "x":3306.96718010786, - "y":1019.64299470573 - }, - { - "type":"Transporter", "height":180.333333333333, "id":6, "name":"Stairs to basement", @@ -375,25 +362,25 @@ "value":9 }], "rotation":0, + "type":"Transporter", "visible":true, "width":30.8333333333335, "x":2509, "y":1101.16666666667 }, { - "type":"", "height":0, "id":9, "name":"upstairs_target", "point":true, "rotation":0, + "type":"", "visible":true, "width":0, "x":2974.13215386677, "y":1237.0995845061 }, { - "type":"Transporter", "height":182.994839833802, "id":11, "name":"Stairs Up", @@ -404,135 +391,124 @@ "value":12 }], "rotation":0, + "type":"Transporter", "visible":true, "width":27.7851494437741, "x":2906.13791716928, "y":1111.01534646831 }, { - "type":"", "height":0, "id":12, "name":"upstairs_target", "point":true, "rotation":0, + "type":"", "visible":true, "width":0, "x":2472.85886610374, "y":1231.73837287227 }, { - "type":"ConversationArea", "height":125.333333333333, "id":22, "name":"Foyer Table 6", "rotation":0, + "type":"ConversationArea", "visible":true, "width":192.666666666667, "x":1342.66666666667, "y":1105.33333333333 }, { - "type":"ConversationArea", "height":125.333, "id":23, "name":"Foyer Table 7", "rotation":0, + "type":"ConversationArea", "visible":true, "width":192.667, "x":1598.99983333333, "y":1104.00016666667 }, { - "type":"ConversationArea", "height":125.333, "id":24, "name":"Foyer Table 4", "rotation":0, + "type":"ConversationArea", "visible":true, "width":192.667, "x":832.333166666666, "y":1105.3335 }, { - "type":"ConversationArea", "height":125.333, "id":25, "name":"Foyer Table 5", "rotation":0, + "type":"ConversationArea", "visible":true, "width":192.667, "x":1085.6665, "y":1104.00016666667 }, { - "type":"ConversationArea", "height":125.333, "id":26, "name":"Foyer Table 2", "rotation":0, + "type":"ConversationArea", "visible":true, "width":192.667, "x":381.6665, "y":1104.00016666667 }, { - "type":"ConversationArea", "height":125.333, "id":27, "name":"Foyer Table 3", "rotation":0, + "type":"ConversationArea", "visible":true, "width":188.722382430673, "x":610.277784235994, "y":1105.3335 }, { - "type":"ConversationArea", "height":125.333, "id":28, "name":"Foyer Table 1", "rotation":0, + "type":"ConversationArea", "visible":true, "width":103.333666666667, "x":226.333166666667, "y":1104.66683333333 }, { - "type":"ConversationArea", - "height":125.333, - "id":29, - "name":"Basement Lounge", - "rotation":0, - "visible":true, - "width":134.000333333333, - "x":2943.6665, - "y":1028.66683333333 - }, - { - "type":"ConversationArea", "height":223.999666666667, "id":30, - "name":"Basement Dining Table 1", + "name":"Basement Dining Table 2", "rotation":0, + "type":"ConversationArea", "visible":true, "width":164.667, "x":3260.99983333333, "y":1056.00016666667 }, { - "type":"ConversationArea", "height":224, "id":31, - "name":"Basement Dining Table 2", + "name":"Basement Dining Table 3", "rotation":0, + "type":"ConversationArea", "visible":true, "width":164.667, "x":3482.99983333333, "y":1058.66666666667 }, { - "type":"ViewingArea", "height":123.333333333333, "id":35, "name":"Fish tank", @@ -543,13 +519,13 @@ "value":"https:\/\/www.youtube.com\/watch?v=l40Ef1dg-6s" }], "rotation":0, + "type":"ViewingArea", "visible":true, "width":141.666666666667, - "x":3248.33333333333, + "x":3312.33333333333, "y":868.333333333333 }, { - "type":"ViewingArea", "height":150.833333333333, "id":37, "name":"Basement TV", @@ -560,10 +536,79 @@ "value":"https:\/\/www.youtube.com\/watch?v=GEgheE1rdXo" }], "rotation":0, + "type":"ViewingArea", "visible":true, "width":158.333333333333, "x":3538.33333333333, - "y":826.666666666667 + "y":730.666666666667 + }, + { + "height":92.2796984663374, + "id":39, + "name":"Tic Tac Toe 2", + "properties":[ + { + "name":"type", + "type":"string", + "value":"TicTacToe" + }], + "rotation":0, + "type":"GameArea", + "visible":true, + "width":135.170262542241, + "x":2941.25292435664, + "y":738.416428385755 + }, + { + "height":83.8442547785938, + "id":43, + "name":"Tic Tac Toe 1", + "properties":[ + { + "name":"type", + "type":"string", + "value":"TicTacToe" + }], + "rotation":0, + "type":"GameArea", + "visible":true, + "width":131.271120353522, + "x":2943.98493302529, + "y":904.2207668969 + }, + { + "height":0, + "id":45, + "name":"Spawn Point", + "point":true, + "rotation":0, + "type":"", + "visible":true, + "width":0, + "x":3110.91365347094, + "y":878.861767209433 + }, + { + "height":216.317265322473, + "id":48, + "name":"Basement Dining Table 1", + "rotation":0, + "type":"ConversationArea", + "visible":true, + "width":152.223260782481, + "x":3043.12992388837, + "y":1061.55695019362 + }, + { + "height":0, + "id":49, + "name":"", + "rotation":0, + "type":"", + "visible":true, + "width":1.33529176124966, + "x":3296.83535852584, + "y":1068.23340899987 }], "opacity":1, "type":"objectgroup", @@ -572,10 +617,10 @@ "y":0 }], "nextlayerid":8, - "nextobjectid":39, + "nextobjectid":52, "orientation":"orthogonal", "renderorder":"right-down", - "tiledversion":"1.9.0", + "tiledversion":"1.10.2", "tileheight":32, "tilesets":[ { @@ -15799,11 +15844,11 @@ "name":"", "objects":[ { - "type":"", "height":28, "id":6, "name":"", "rotation":0, + "type":"", "visible":true, "width":20, "x":0, @@ -32699,11 +32744,11 @@ "name":"", "objects":[ { - "type":"", "height":25.995583882738, "id":1, "name":"", "rotation":0, + "type":"", "visible":true, "width":13.6241915530012, "x":18.087288785881, @@ -32730,11 +32775,11 @@ "name":"", "objects":[ { - "type":"", "height":25.4474842225598, "id":1, "name":"", "rotation":0, + "type":"", "visible":true, "width":31.6331803874282, "x":0.15659990290806, @@ -32761,7 +32806,6 @@ "name":"", "objects":[ { - "type":"", "height":26.1521837856461, "id":2, "name":"", @@ -32772,6 +32816,7 @@ "value":true }], "rotation":0, + "type":"", "visible":true, "width":12.2147924268287, "x":0, @@ -32798,11 +32843,11 @@ "name":"", "objects":[ { - "type":"", "height":32.3378799505144, "id":1, "name":"", "rotation":0, + "type":"", "visible":true, "width":18.4004885916971, "x":14.2505911646335, @@ -32838,7 +32883,6 @@ "name":"", "objects":[ { - "type":"collision", "height":31.319980581612, "id":1, "name":"", @@ -32849,6 +32893,7 @@ "value":false }], "rotation":0, + "type":"collision", "visible":true, "width":12.0581925239206, "x":0.0782999514540297, @@ -32875,11 +32920,11 @@ "name":"", "objects":[ { - "type":"", "height":31.5548804359741, "id":1, "name":"", "rotation":0, + "type":"", "visible":true, "width":13.6241915530012, "x":18.087288785881, @@ -32915,11 +32960,11 @@ "name":"", "objects":[ { - "type":"", "height":31.7897802903362, "id":1, "name":"", "rotation":0, + "type":"", "visible":true, "width":11.9798925724666, "x":-0.156599902908059, @@ -32957,7 +33002,7 @@ }] }, { - "id":404, + "id":359, "properties":[ { "name":"collides", @@ -32966,7 +33011,7 @@ }] }, { - "id":405, + "id":360, "properties":[ { "name":"collides", @@ -32975,7 +33020,7 @@ }] }, { - "id":406, + "id":361, "properties":[ { "name":"collides", @@ -32984,7 +33029,7 @@ }] }, { - "id":407, + "id":375, "properties":[ { "name":"collides", @@ -32993,7 +33038,7 @@ }] }, { - "id":420, + "id":376, "properties":[ { "name":"collides", @@ -33001,6 +33046,60 @@ "value":true }] }, + { + "id":377, + "properties":[ + { + "name":"collides", + "type":"bool", + "value":true + }] + }, + { + "id":404, + "properties":[ + { + "name":"collides", + "type":"bool", + "value":false + }] + }, + { + "id":405, + "properties":[ + { + "name":"collides", + "type":"bool", + "value":false + }] + }, + { + "id":406, + "properties":[ + { + "name":"collides", + "type":"bool", + "value":false + }] + }, + { + "id":407, + "properties":[ + { + "name":"collides", + "type":"bool", + "value":false + }] + }, + { + "id":420, + "properties":[ + { + "name":"collides", + "type":"bool", + "value":false + }] + }, { "id":421, "properties":[ @@ -33025,7 +33124,7 @@ { "name":"collides", "type":"bool", - "value":true + "value":false }] }, { @@ -33034,7 +33133,7 @@ { "name":"collides", "type":"bool", - "value":true + "value":false }] }, { @@ -33043,7 +33142,7 @@ { "name":"collides", "type":"bool", - "value":true + "value":false }] }, { @@ -33052,7 +33151,7 @@ { "name":"collides", "type":"bool", - "value":true + "value":false }] }, { @@ -33061,7 +33160,7 @@ { "name":"collides", "type":"bool", - "value":true + "value":false }] }, { @@ -33262,6 +33361,24 @@ "value":true }] }, + { + "id":663, + "properties":[ + { + "name":"collides", + "type":"bool", + "value":true + }] + }, + { + "id":679, + "properties":[ + { + "name":"collides", + "type":"bool", + "value":true + }] + }, { "id":688, "properties":[ @@ -33320,11 +33437,11 @@ "name":"", "objects":[ { - "type":"", "height":32.3378799505144, "id":1, "name":"", "rotation":0, + "type":"", "visible":true, "width":10.1789936890239, "x":21.8456864556744, @@ -33351,11 +33468,11 @@ "name":"", "objects":[ { - "type":"", "height":31.8680802417902, "id":1, "name":"", "rotation":0, + "type":"", "visible":true, "width":10.0223937861159, "x":0, @@ -33382,11 +33499,11 @@ "name":"", "objects":[ { - "type":"", "height":18.7136883975132, "id":1, "name":"", "rotation":0, + "type":"", "visible":true, "width":9.39599417448361, "x":22.3937861158526, @@ -33413,11 +33530,11 @@ "name":"", "objects":[ { - "type":"", "height":15.659990290806, "id":1, "name":"", "rotation":0, + "type":"", "visible":true, "width":32.3378799505144, "x":-0.234899854362091, @@ -33444,11 +33561,11 @@ "name":"", "objects":[ { - "type":"", "height":15.581690339352, "id":1, "name":"", "rotation":0, + "type":"", "visible":true, "width":31.7897802903362, "x":0, @@ -33475,11 +33592,11 @@ "name":"", "objects":[ { - "type":"", "height":16.1297899995302, "id":1, "name":"", "rotation":0, + "type":"", "visible":true, "width":32.4161799019685, "x":-0.469799708724182, @@ -33506,11 +33623,11 @@ "name":"", "objects":[ { - "type":"", "height":22.4720860673066, "id":1, "name":"", "rotation":0, + "type":"", "visible":true, "width":8.61299465994331, "x":0.939599417448361, @@ -33537,7 +33654,6 @@ "name":"", "objects":[ { - "type":"", "height":9.23939427157555, "id":1, "name":"", @@ -33548,6 +33664,7 @@ "value":true }], "rotation":0, + "type":"", "visible":true, "width":28.1879825234508, "x":1.95749878635075, @@ -33649,6 +33766,33 @@ "type":"bool", "value":true }] + }, + { + "id":950, + "properties":[ + { + "name":"collides", + "type":"bool", + "value":true + }] + }, + { + "id":951, + "properties":[ + { + "name":"collides", + "type":"bool", + "value":true + }] + }, + { + "id":952, + "properties":[ + { + "name":"collides", + "type":"bool", + "value":true + }] }], "tilewidth":32 }, @@ -33695,10 +33839,20 @@ "spacing":0, "tilecount":1248, "tileheight":32, + "tiles":[ + { + "id":85, + "properties":[ + { + "name":"collides", + "type":"bool", + "value":true + }] + }], "tilewidth":32 }], "tilewidth":32, "type":"map", - "version":"1.9", + "version":"1.10", "width":120 } \ No newline at end of file diff --git a/frontend/public/index.html b/frontend/public/index.html deleted file mode 100644 index 339e12cc5..000000000 --- a/frontend/public/index.html +++ /dev/null @@ -1,43 +0,0 @@ - - - - - - - - - - - - - Covey.Town - - - -
- - - diff --git a/frontend/public/virtualbackground/twilio-video-processors.js b/frontend/public/virtualbackground/twilio-video-processors.js index 284eaaaef..99cf1f48f 100644 --- a/frontend/public/virtualbackground/twilio-video-processors.js +++ b/frontend/public/virtualbackground/twilio-video-processors.js @@ -447,11 +447,11 @@ documented below. */ set: function (radius) { if (typeof radius !== 'number' || radius < 0) { - console.warn( - 'Valid mask blur radius not found. Using ' + - constants_1.MASK_BLUR_RADIUS + - ' as default.', - ); + // console.warn( + // 'Valid mask blur radius not found. Using ' + + // constants_1.MASK_BLUR_RADIUS + + // ' as default.', + // ); radius = constants_1.MASK_BLUR_RADIUS; } this._maskBlurRadius = radius; diff --git a/frontend/public/virtualbackground/twilio-video-processors.min.js b/frontend/public/virtualbackground/twilio-video-processors.min.js index 6c2d2566c..795b9af34 100644 --- a/frontend/public/virtualbackground/twilio-video-processors.min.js +++ b/frontend/public/virtualbackground/twilio-video-processors.min.js @@ -426,11 +426,11 @@ documented below. }, set: function (radius) { if (typeof radius !== 'number' || radius < 0) { - console.warn( - 'Valid mask blur radius not found. Using ' + - constants_1.MASK_BLUR_RADIUS + - ' as default.', - ); + // console.warn( + // 'Valid mask blur radius not found. Using ' + + // constants_1.MASK_BLUR_RADIUS + + // ' as default.', + // ); radius = constants_1.MASK_BLUR_RADIUS; } this._maskBlurRadius = radius; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 44aac86f0..5dc45260f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,7 +1,7 @@ import { ChakraProvider } from '@chakra-ui/react'; import { MuiThemeProvider } from '@material-ui/core/styles'; import assert from 'assert'; -import React, { useCallback, useState } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { BrowserRouter } from 'react-router-dom'; import TownController from './classes/TownController'; import { ChatProvider } from './components/VideoCall/VideoFrontend/components/ChatProvider'; @@ -17,6 +17,7 @@ import TownMap from './components/Town/TownMap'; import TownControllerContext from './contexts/TownControllerContext'; import LoginControllerContext from './contexts/LoginControllerContext'; import { TownsServiceClient } from './generated/client'; +import { nanoid } from 'nanoid'; function App() { const [townController, setTownController] = useState(null); @@ -41,7 +42,7 @@ function App() { page = ; } const url = process.env.NEXT_PUBLIC_TOWNS_SERVICE_URL; - assert(url); + assert(url, 'NEXT_PUBLIC_TOWNS_SERVICE_URL must be defined'); const townsService = new TownsServiceClient({ BASE: url }).towns; return ( @@ -55,13 +56,82 @@ function App() { ); } +const DEBUG_TOWN_NAME = 'DEBUG_TOWN'; +function DebugApp(): JSX.Element { + const [townController, setTownController] = useState(null); + useEffect(() => { + const url = process.env.NEXT_PUBLIC_TOWNS_SERVICE_URL; + assert(url, 'NEXT_PUBLIC_TOWNS_SERVICE_URL must be defined'); + const townsService = new TownsServiceClient({ BASE: url }).towns; + async function getOrCreateDebugTownID() { + const towns = await townsService.listTowns(); + const existingTown = towns.find(town => town.friendlyName === DEBUG_TOWN_NAME); + if (existingTown) { + return existingTown.townID; + } else { + try { + const newTown = await townsService.createTown({ + friendlyName: DEBUG_TOWN_NAME, + isPubliclyListed: true, + }); + return newTown.townID; + } catch (e) { + console.error(e); + //Try one more time to see if the town had been created by another process + const townsRetry = await townsService.listTowns(); + const existingTownRetry = townsRetry.find(town => town.friendlyName === DEBUG_TOWN_NAME); + if (!existingTownRetry) { + throw e; + } else { + return existingTownRetry.townID; + } + } + } + } + getOrCreateDebugTownID().then(townID => { + assert(townID); + const newTownController = new TownController({ + townID, + loginController: { + setTownController: () => {}, + townsService, + }, + userName: nanoid(), + }); + newTownController.connect().then(() => { + setTownController(newTownController); + }); + }); + }, []); + if (!townController) { + return
Loading...
; + } else { + return ( + + + + + + ); + } +} + +function AppOrDebugApp(): JSX.Element { + const debugTown = process.env.NEXT_PUBLIC_TOWN_DEV_MODE; + if (debugTown && debugTown.toLowerCase() === 'true') { + return ; + } else { + return ; + } +} + export default function AppStateWrapper(): JSX.Element { return ( - + diff --git a/frontend/src/TestUtils.ts b/frontend/src/TestUtils.ts index 2b71c71c1..bed33bc82 100644 --- a/frontend/src/TestUtils.ts +++ b/frontend/src/TestUtils.ts @@ -1,12 +1,18 @@ import { ReservedOrUserListener } from '@socket.io/component-emitter'; import { mock, MockProxy } from 'jest-mock-extended'; import { nanoid } from 'nanoid'; -import ConversationAreaController from './classes/ConversationAreaController'; +import ConversationAreaController from './classes/interactable/ConversationAreaController'; import PlayerController from './classes/PlayerController'; import TownController, { TownEvents } from './classes/TownController'; -import ViewingAreaController from './classes/ViewingAreaController'; +import ViewingAreaController from './classes/interactable/ViewingAreaController'; import { TownsService } from './generated/client'; -import { CoveyTownSocket, ServerToClientEvents, TownJoinResponse } from './types/CoveyTownSocket'; +import { + ConversationArea, + CoveyTownSocket, + ServerToClientEvents, + TownJoinResponse, + ViewingArea, +} from './types/CoveyTownSocket'; //These types copied from socket.io server library so that we don't have to depend on the whole thing to have type-safe tests. type SocketReservedEventsMap = { @@ -118,10 +124,12 @@ export function mockTownController({ Object.defineProperty(mockedController, 'players', { value: players }); } if (conversationAreas) { + Object.defineProperty(mockedController, 'interactableAreas', { value: conversationAreas }); Object.defineProperty(mockedController, 'conversationAreas', { value: conversationAreas }); } if (viewingAreas) { - Object.defineProperty(mockedController, 'viewingAreas', { value: viewingAreas }); + Object.defineProperty(mockedController, 'interactableAreas', { value: viewingAreas }); + Object.defineProperty(mockedController, 'viewingAreas', { value: conversationAreas }); } return mockedController; } @@ -163,8 +171,9 @@ export async function mockTownControllerConnection( responseToSendController.interactables.push({ id: nanoid(), topic: undefined, - occupantsByID: [], - }); + occupants: [], + type: 'ConversationArea', + } as ConversationArea); for (let i = 0; i < 10; i++) { const playerID = nanoid(); responseToSendController.currentPlayers.push({ @@ -175,14 +184,17 @@ export async function mockTownControllerConnection( responseToSendController.interactables.push({ id: nanoid(), topic: nanoid(), - occupantsByID: [playerID], - }); + occupants: [playerID], + type: 'ConversationArea', + } as ConversationArea); responseToSendController.interactables.push({ id: nanoid(), video: nanoid(), elapsedTimeSec: 0, isPlaying: false, - }); + occupants: [], + type: 'ViewingArea', + } as ViewingArea); } } mockSocket.on.mockImplementationOnce((eventName, eventListener) => { diff --git a/frontend/src/classes/PlayerController.ts b/frontend/src/classes/PlayerController.ts index 88f84f6b4..c202695fa 100644 --- a/frontend/src/classes/PlayerController.ts +++ b/frontend/src/classes/PlayerController.ts @@ -1,6 +1,7 @@ import EventEmitter from 'events'; import TypedEmitter from 'typed-emitter'; import { Player as PlayerModel, PlayerLocation } from '../types/CoveyTownSocket'; +export const MOVEMENT_SPEED = 175; export type PlayerEvents = { movement: (newLocation: PlayerLocation) => void; @@ -55,14 +56,30 @@ export default class PlayerController extends (EventEmitter as new () => TypedEm if (!sprite.anims) return; sprite.setX(this.location.x); sprite.setY(this.location.y); - label.setX(this.location.x); - label.setY(this.location.y - 20); if (this.location.moving) { sprite.anims.play(`misa-${this.location.rotation}-walk`, true); + switch (this.location.rotation) { + case 'front': + sprite.body.setVelocity(0, MOVEMENT_SPEED); + break; + case 'right': + sprite.body.setVelocity(MOVEMENT_SPEED, 0); + break; + case 'back': + sprite.body.setVelocity(0, -MOVEMENT_SPEED); + break; + case 'left': + sprite.body.setVelocity(-MOVEMENT_SPEED, 0); + break; + } + sprite.body.velocity.normalize().scale(175); } else { + sprite.body.setVelocity(0, 0); sprite.anims.stop(); sprite.setTexture('atlas', `misa-${this.location.rotation}`); } + label.setX(sprite.body.x); + label.setY(sprite.body.y - 20); } } diff --git a/frontend/src/classes/TownController.test.ts b/frontend/src/classes/TownController.test.ts index 0da155a80..dd6acb5ee 100644 --- a/frontend/src/classes/TownController.test.ts +++ b/frontend/src/classes/TownController.test.ts @@ -1,7 +1,6 @@ import { mock, mockClear, MockProxy } from 'jest-mock-extended'; import { nanoid } from 'nanoid'; import { LoginController } from '../contexts/LoginControllerContext'; -import { ViewingArea } from '../generated/client'; import { EventNames, getEventListener, @@ -16,11 +15,12 @@ import { PlayerLocation, ServerToClientEvents, TownJoinResponse, + ViewingArea, } from '../types/CoveyTownSocket'; import { isConversationArea, isViewingArea } from '../types/TypeUtils'; import PlayerController from './PlayerController'; import TownController, { TownEvents } from './TownController'; -import ViewingAreaController from './ViewingAreaController'; +import ViewingAreaController from './interactable/ViewingAreaController'; /** * Mocks the socket-io client constructor such that it will always return the same @@ -164,20 +164,18 @@ describe('TownController', () => { expect(mockSocket.emit).toBeCalledWith('chatMessage', testMessage); }); - it('Emits conversationAreasChanged when a conversation area is created', () => { + it('Emits interactableAreasChanged when a conversation area is created', () => { const newConvArea = townJoinResponse.interactables.find( eachInteractable => isConversationArea(eachInteractable) && !eachInteractable.topic, ) as ConversationAreaModel; if (newConvArea) { newConvArea.topic = nanoid(); - newConvArea.occupantsByID = [townJoinResponse.userID]; - const event = emitEventAndExpectListenerFiring( + newConvArea.occupants = [townJoinResponse.userID]; + emitEventAndExpectListenerFiring( 'interactableUpdate', newConvArea, - 'conversationAreasChanged', + 'interactableAreasChanged', ); - const changedAreasArray = event.mock.calls[0][0]; - expect(changedAreasArray.find(eachConvArea => eachConvArea.id === newConvArea.id)?.topic); } else { fail('Did not find an existing, empty conversation area in the town join response'); } @@ -188,7 +186,7 @@ describe('TownController', () => { return { ...(townJoinResponse.interactables.find( eachInteractable => - isConversationArea(eachInteractable) && eachInteractable.occupantsByID.length == 0, + isConversationArea(eachInteractable) && eachInteractable.occupants.length == 0, ) as ConversationAreaModel), }; } @@ -196,21 +194,20 @@ describe('TownController', () => { return { ...(townJoinResponse.interactables.find( eachInteractable => - isConversationArea(eachInteractable) && eachInteractable.occupantsByID.length > 0, + isConversationArea(eachInteractable) && eachInteractable.occupants.length > 0, ) as ConversationAreaModel), }; } - it('Emits a conversationAreasChanged event with the updated list of conversation areas if the area is newly occupied', () => { + it('Emits a interactableAreasChanged event with the updated list of conversation areas if the area is newly occupied', () => { const convArea = emptyConversationArea(); - convArea.occupantsByID = [townJoinResponse.userID]; + convArea.occupants = [townJoinResponse.userID]; convArea.topic = nanoid(); const updatedConversationAreas = testController.conversationAreas; emitEventAndExpectListenerFiring( 'interactableUpdate', convArea, - 'conversationAreasChanged', - updatedConversationAreas, + 'interactableAreasChanged', ); const updatedController = updatedConversationAreas.find( @@ -218,32 +215,32 @@ describe('TownController', () => { ); expect(updatedController?.topic).toEqual(convArea.topic); expect(updatedController?.occupants.map(eachOccupant => eachOccupant.id)).toEqual( - convArea.occupantsByID, + convArea.occupants, ); - expect(updatedController?.toConversationAreaModel()).toEqual({ + expect(updatedController?.toInteractableAreaModel()).toEqual({ id: convArea.id, topic: convArea.topic, - occupantsByID: [townJoinResponse.userID], + occupants: [townJoinResponse.userID], + type: 'ConversationArea', }); }); - it('Emits a conversationAreasChanged event with the updated list of converation areas if the area is newly vacant', () => { + it('Emits a interactableAreasChanged event with the updated list of converation areas if the area is newly vacant', () => { const convArea = occupiedConversationArea(); - convArea.occupantsByID = []; + convArea.occupants = []; convArea.topic = undefined; const updatedConversationAreas = testController.conversationAreas; emitEventAndExpectListenerFiring( 'interactableUpdate', convArea, - 'conversationAreasChanged', - updatedConversationAreas, + 'interactableAreasChanged', ); const updatedController = updatedConversationAreas.find( eachArea => eachArea.id === convArea.id, ); expect(updatedController?.topic).toEqual(convArea.topic); expect(updatedController?.occupants.map(eachOccupant => eachOccupant.id)).toEqual( - convArea.occupantsByID, + convArea.occupants, ); }); it('Does not emit a conversationAreasChanged event if the set of active areas has not changed', () => { @@ -253,9 +250,9 @@ describe('TownController', () => { const eventListener = getEventListener(mockSocket, 'interactableUpdate'); const mockListener = jest.fn() as jest.MockedFunction< - TownEvents['conversationAreasChanged'] + TownEvents['interactableAreasChanged'] >; - testController.addListener('conversationAreasChanged', mockListener); + testController.addListener('interactableAreasChanged', mockListener); eventListener(convArea); expect(mockListener).not.toBeCalled(); @@ -264,7 +261,7 @@ describe('TownController', () => { ); expect(updatedController?.topic).toEqual(convArea.topic); expect(updatedController?.occupants.map(eachOccupant => eachOccupant.id)).toEqual( - convArea.occupantsByID, + convArea.occupants, ); }); it('Emits a topicChange event if the topic of a conversation area changes', () => { @@ -307,7 +304,7 @@ describe('TownController', () => { }); it('Emits an occupantsChange event if the occupants changed', () => { const convArea = occupiedConversationArea(); - convArea.occupantsByID = [townJoinResponse.userID, townJoinResponse.currentPlayers[1].id]; + convArea.occupants = [townJoinResponse.userID, townJoinResponse.currentPlayers[1].id]; //Set up an occupantsChange listener const occupantsChangeListener = jest.fn(); @@ -375,7 +372,7 @@ describe('TownController', () => { eventListener(viewingArea); - expect(viewingAreaController.viewingAreaModel()).toEqual(viewingArea); + expect(viewingAreaController.toInteractableAreaModel()).toEqual(viewingArea); }); it('Emits a playbackChange event if isPlaying changes', () => { const listener = jest.fn(); diff --git a/frontend/src/classes/TownController.ts b/frontend/src/classes/TownController.ts index 2212dfa9a..3afeb2ab8 100644 --- a/frontend/src/classes/TownController.ts +++ b/frontend/src/classes/TownController.ts @@ -1,10 +1,13 @@ import assert from 'assert'; import EventEmitter from 'events'; import _ from 'lodash'; +import { nanoid } from 'nanoid'; import { useEffect, useState } from 'react'; import { io } from 'socket.io-client'; import TypedEmitter from 'typed-emitter'; import Interactable from '../components/Town/Interactable'; +import ConversationArea from '../components/Town/interactables/ConversationArea'; +import GameArea from '../components/Town/interactables/GameArea'; import ViewingArea from '../components/Town/interactables/ViewingArea'; import { LoginController } from '../contexts/LoginControllerContext'; import { TownsService, TownsServiceClient } from '../generated/client'; @@ -12,16 +15,29 @@ import useTownController from '../hooks/useTownController'; import { ChatMessage, CoveyTownSocket, + GameState, + Interactable as InteractableAreaModel, + InteractableCommand, + InteractableCommandBase, + InteractableCommandResponse, + InteractableID, + PlayerID, PlayerLocation, TownSettingsUpdate, ViewingArea as ViewingAreaModel, } from '../types/CoveyTownSocket'; -import { isConversationArea, isViewingArea } from '../types/TypeUtils'; -import ConversationAreaController from './ConversationAreaController'; +import { isConversationArea, isTicTacToeArea, isViewingArea } from '../types/TypeUtils'; +import ConversationAreaController from './interactable/ConversationAreaController'; +import GameAreaController, { GameEventTypes } from './interactable/GameAreaController'; +import InteractableAreaController, { + BaseInteractableEventMap, +} from './interactable/InteractableAreaController'; +import TicTacToeAreaController from './interactable/TicTacToeAreaController'; +import ViewingAreaController from './interactable/ViewingAreaController'; import PlayerController from './PlayerController'; -import ViewingAreaController from './ViewingAreaController'; -const CALCULATE_NEARBY_PLAYERS_DELAY = 300; +const CALCULATE_NEARBY_PLAYERS_DELAY_MS = 300; +const SOCKET_COMMAND_TIMEOUT_MS = 5000; export type ConnectionProperties = { userName: string; @@ -58,17 +74,13 @@ export type TownEvents = { * the new location can be found on the PlayerController. */ playerMoved: (movedPlayer: PlayerController) => void; + /** - * An event that indicates that the set of conversation areas has changed. This event is dispatched - * when a conversation area is created, or when the set of active conversations has changed. This event is dispatched - * after updating the town controller's record of conversation areas. - */ - conversationAreasChanged: (currentConversationAreas: ConversationAreaController[]) => void; - /** - * An event that indicates that the set of viewing areas has changed. This event is emitted after updating - * the town controller's record of viewing areas. + * An event that indicates that the set of active interactable areas has changed. This event is dispatched + * after updating the set of interactable areas - the new set of interactable areas can be found on the TownController. */ - viewingAreasChanged: (newViewingAreas: ViewingAreaController[]) => void; + interactableAreasChanged: () => void; + /** * An event that indicates that a new chat message has been received, which is the parameter passed to the listener */ @@ -127,10 +139,12 @@ export default class TownController extends (EventEmitter as new () => TypedEmit private _playersInternal: PlayerController[] = []; /** - * The current list of conversation areas in the twon. Adding or removing conversation areas might - * replace the array with a new one; clients should take note not to retain stale references. + * The current list of interactable areas in the town. Adding or removing interactable areas might replace the array. */ - private _conversationAreasInternal: ConversationAreaController[] = []; + private _interactableControllers: InteractableAreaController< + BaseInteractableEventMap, + InteractableAreaModel + >[] = []; /** * The friendly name of the current town, set only once this TownController is connected to the townsService @@ -188,8 +202,6 @@ export default class TownController extends (EventEmitter as new () => TypedEmit */ private _interactableEmitter = new EventEmitter(); - private _viewingAreas: ViewingAreaController[] = []; - public constructor({ userName, townID, loginController }: ConnectionProperties) { super(); this._townID = townID; @@ -287,13 +299,17 @@ export default class TownController extends (EventEmitter as new () => TypedEmit this._playersInternal = newPlayers; } - public get conversationAreas() { - return this._conversationAreasInternal; + public getPlayer(id: PlayerID) { + const ret = this._playersInternal.find(eachPlayer => eachPlayer.id === id); + assert(ret); + return ret; } - private set _conversationAreas(newConversationAreas: ConversationAreaController[]) { - this._conversationAreasInternal = newConversationAreas; - this.emit('conversationAreasChanged', newConversationAreas); + public get conversationAreas(): ConversationAreaController[] { + const ret = this._interactableControllers.filter( + eachInteractable => eachInteractable instanceof ConversationAreaController, + ); + return ret as ConversationAreaController[]; } public get interactableEmitter() { @@ -301,12 +317,17 @@ export default class TownController extends (EventEmitter as new () => TypedEmit } public get viewingAreas() { - return this._viewingAreas; + const ret = this._interactableControllers.filter( + eachInteractable => eachInteractable instanceof ViewingAreaController, + ); + return ret as ViewingAreaController[]; } - public set viewingAreas(newViewingAreas: ViewingAreaController[]) { - this._viewingAreas = newViewingAreas; - this.emit('viewingAreasChanged', newViewingAreas); + public get gameAreas() { + const ret = this._interactableControllers.filter( + eachInteractable => eachInteractable instanceof GameAreaController, + ); + return ret as GameAreaController[]; } /** @@ -391,42 +412,32 @@ export default class TownController extends (EventEmitter as new () => TypedEmit playerToUpdate.location = movedPlayer.location; } this.emit('playerMoved', playerToUpdate); - } else { - //TODO: It should not be possible to receive a playerMoved event for a player that is not already in the players array, right? - const newPlayer = PlayerController.fromPlayerModel(movedPlayer); - this._players = this.players.concat(newPlayer); - this.emit('playerMoved', newPlayer); } }); /** - * When an interactable's state changes, push that update into the relevant controller, which is assumed - * to be either a Viewing Area or a Conversation Area, and which is assumed to already be represented by a - * ViewingAreaController or ConversationAreaController that this TownController has. + * When an interactable's state changes, push that update into the relevant controller * - * If a conversation area transitions from empty to occupied (or occupied to empty), this handler will emit - * a conversationAreasChagned event to listeners of this TownController. + * If an interactable area transitions from active to inactive (or inactive to active), this handler will emit + * an interactableAreasChanged event to listeners of this TownController. * * If the update changes properties of the interactable, the interactable is also expected to emit its own - * events (@see ViewingAreaController and @see ConversationAreaController) + * events. */ this._socket.on('interactableUpdate', interactable => { - if (isConversationArea(interactable)) { - const updatedConversationArea = this.conversationAreas.find(c => c.id === interactable.id); - if (updatedConversationArea) { - const emptyNow = updatedConversationArea.isEmpty(); - updatedConversationArea.topic = interactable.topic; - updatedConversationArea.occupants = this._playersByIDs(interactable.occupantsByID); - const emptyAfterChange = updatedConversationArea.isEmpty(); - if (emptyNow !== emptyAfterChange) { - this.emit('conversationAreasChanged', this._conversationAreasInternal); + try { + const controller = this._interactableControllers.find(c => c.id === interactable.id); + if (controller) { + const activeBefore = controller.isActive(); + controller.updateFrom(interactable, this._playersByIDs(interactable.occupants)); + const activeNow = controller.isActive(); + if (activeBefore !== activeNow) { + this.emit('interactableAreasChanged'); } } - } else if (isViewingArea(interactable)) { - const updatedViewingArea = this._viewingAreas.find( - eachArea => eachArea.id === interactable.id, - ); - updatedViewingArea?.updateFrom(interactable); + } catch (err) { + console.error('Error updating interactable', interactable); + console.trace(err); } }); } @@ -457,6 +468,51 @@ export default class TownController extends (EventEmitter as new () => TypedEmit this._socket.emit('chatMessage', message); } + /** + * Sends an InteractableArea command to the townService. Returns a promise that resolves + * when the command is acknowledged by the server. + * + * If the command is not acknowledged within SOCKET_COMMAND_TIMEOUT_MS, the promise will reject. + * + * If the command is acknowledged successfully, the promise will resolve with the payload of the response. + * + * If the command is acknowledged with an error, the promise will reject with the error. + * + * @param interactableID ID of the interactable area to send the command to + * @param command The command to send @see InteractableCommand + * @returns A promise for the InteractableResponse corresponding to the command + * + **/ + public async sendInteractableCommand( + interactableID: InteractableID, + command: CommandType, + ): Promise['payload']> { + const commandMessage: InteractableCommand & InteractableCommandBase = { + ...command, + commandID: nanoid(), + interactableID: interactableID, + }; + return new Promise((resolve, reject) => { + const watchdog = setTimeout(() => { + reject('Command timed out'); + }, SOCKET_COMMAND_TIMEOUT_MS); + + const ackListener = (response: InteractableCommandResponse) => { + if (response.commandID === commandMessage.commandID) { + clearTimeout(watchdog); + this._socket.off('commandResponse', ackListener); + if (response.error) { + reject(response.error); + } else { + resolve(response.payload); + } + } + }; + this._socket.on('commandResponse', ackListener); + this._socket.emit('interactableCommand', commandMessage); + }); + } + /** * Update the settings of the current town. Sends the request to update the settings to the townService, * and does not update the local model. If the update is successful, then the townService will inform us @@ -489,11 +545,7 @@ export default class TownController extends (EventEmitter as new () => TypedEmit * * @param newArea */ - async createConversationArea(newArea: { - topic?: string; - id: string; - occupantsByID: Array; - }) { + async createConversationArea(newArea: { topic?: string; id: string; occupants: Array }) { await this._townsService.createConversationArea(this.townID, this.sessionToken, newArea); } @@ -504,7 +556,7 @@ export default class TownController extends (EventEmitter as new () => TypedEmit * * @param newArea */ - async createViewingArea(newArea: ViewingAreaModel) { + async createViewingArea(newArea: Omit) { await this._townsService.createViewingArea(this.townID, this.sessionToken, newArea); } @@ -538,18 +590,21 @@ export default class TownController extends (EventEmitter as new () => TypedEmit PlayerController.fromPlayerModel(eachPlayerModel), ); - this._conversationAreas = []; - this._viewingAreas = []; + this._interactableControllers = []; initialData.interactables.forEach(eachInteractable => { if (isConversationArea(eachInteractable)) { - this._conversationAreasInternal.push( + this._interactableControllers.push( ConversationAreaController.fromConversationAreaModel( eachInteractable, this._playersByIDs.bind(this), ), ); } else if (isViewingArea(eachInteractable)) { - this._viewingAreas.push(new ViewingAreaController(eachInteractable)); + this._interactableControllers.push(new ViewingAreaController(eachInteractable)); + } else if (isTicTacToeArea(eachInteractable)) { + this._interactableControllers.push( + new TicTacToeAreaController(eachInteractable.id, eachInteractable, this), + ); } }); this._userID = initialData.userID; @@ -570,20 +625,46 @@ export default class TownController extends (EventEmitter as new () => TypedEmit * @returns */ public getViewingAreaController(viewingArea: ViewingArea): ViewingAreaController { - const existingController = this._viewingAreas.find( + const existingController = this._interactableControllers.find( eachExistingArea => eachExistingArea.id === viewingArea.name, ); - if (existingController) { + if (existingController instanceof ViewingAreaController) { return existingController; } else { - const newController = new ViewingAreaController({ - elapsedTimeSec: 0, - id: viewingArea.name, - isPlaying: false, - video: viewingArea.defaultVideoURL, - }); - this._viewingAreas.push(newController); - return newController; + throw new Error(`No such viewing area controller ${existingController}`); + } + } + + public getConversationAreaController( + converationArea: ConversationArea, + ): ConversationAreaController { + const existingController = this._interactableControllers.find( + eachExistingArea => eachExistingArea.id === converationArea.name, + ); + if (existingController instanceof ConversationAreaController) { + return existingController; + } else { + throw new Error(`No such viewing area controller ${existingController}`); + } + } + + /** + * Retrives the game area controller corresponding to a game area by ID, or + * throws an error if the game area controller does not exist + * + * @param gameArea + * @returns + */ + public getGameAreaController( + gameArea: GameArea, + ): GameAreaController { + const existingController = this._interactableControllers.find( + eachExistingArea => eachExistingArea.id === gameArea.name, + ); + if (existingController instanceof GameAreaController) { + return existingController as GameAreaController; + } else { + throw new Error('Game area controller not created'); } } @@ -593,7 +674,7 @@ export default class TownController extends (EventEmitter as new () => TypedEmit * with the event */ public emitViewingAreaUpdate(viewingArea: ViewingAreaController) { - this._socket.emit('interactableUpdate', viewingArea.viewingAreaModel()); + this._socket.emit('interactableUpdate', viewingArea.toInteractableAreaModel()); } /** @@ -655,24 +736,24 @@ export function useTownSettings() { } /** - * A react hook to retrieve a viewing area controller. + * A react hook to retrieve an interactable area controller * - * This function will throw an error if the viewing area controller does not exist. + * This function will throw an error if the interactable area controller does not exist. * * This hook relies on the TownControllerContext. * - * @param viewingAreaID The ID of the viewing area to retrieve the controller for - * - * @throws Error if there is no viewing area controller matching the specifeid ID + * @param interactableAreaID The ID of the interactable area to retrieve the controller for + * @throws Error if there is no interactable area controller matching the specified ID */ -export function useViewingAreaController(viewingAreaID: string): ViewingAreaController { +export function useInteractableAreaController(interactableAreaID: string): T { const townController = useTownController(); - - const viewingArea = townController.viewingAreas.find(eachArea => eachArea.id == viewingAreaID); - if (!viewingArea) { - throw new Error(`Requested viewing area ${viewingAreaID} does not exist`); + const interactableAreaController = townController.gameAreas.find( + eachArea => eachArea.id == interactableAreaID, + ); + if (!interactableAreaController) { + throw new Error(`Requested interactable area ${interactableAreaID} does not exist`); } - return viewingArea; + return interactableAreaController as unknown as T; } /** @@ -690,12 +771,13 @@ export function useActiveConversationAreas(): ConversationAreaController[] { townController.conversationAreas.filter(eachArea => !eachArea.isEmpty()), ); useEffect(() => { - const updater = (allAreas: ConversationAreaController[]) => { + const updater = () => { + const allAreas = townController.conversationAreas; setConversationAreas(allAreas.filter(eachArea => !eachArea.isEmpty())); }; - townController.addListener('conversationAreasChanged', updater); + townController.addListener('interactableAreasChanged', updater); return () => { - townController.removeListener('conversationAreasChanged', updater); + townController.removeListener('interactableAreasChanged', updater); }; }, [townController, setConversationAreas]); return conversationAreas; @@ -780,7 +862,7 @@ export function usePlayersInVideoCall(): PlayerController[] { const updatePlayersInCall = () => { const now = Date.now(); // To reduce re-renders, only recalculate the nearby players every so often - if (now - lastRecalculatedNearbyPlayers > CALCULATE_NEARBY_PLAYERS_DELAY) { + if (now - lastRecalculatedNearbyPlayers > CALCULATE_NEARBY_PLAYERS_DELAY_MS) { lastRecalculatedNearbyPlayers = now; const nearbyPlayers = townController.nearbyPlayers(); if (!samePlayers(nearbyPlayers, prevNearbyPlayers)) { diff --git a/frontend/src/classes/ConversationAreaController.test.ts b/frontend/src/classes/interactable/ConversationAreaController.test.ts similarity index 87% rename from frontend/src/classes/ConversationAreaController.test.ts rename to frontend/src/classes/interactable/ConversationAreaController.test.ts index 422582ee0..63dbd530c 100644 --- a/frontend/src/classes/ConversationAreaController.test.ts +++ b/frontend/src/classes/interactable/ConversationAreaController.test.ts @@ -1,8 +1,8 @@ import { mock, mockClear } from 'jest-mock-extended'; import { nanoid } from 'nanoid'; -import { PlayerLocation } from '../types/CoveyTownSocket'; +import { PlayerLocation } from '../../types/CoveyTownSocket'; import ConversationAreaController, { ConversationAreaEvents } from './ConversationAreaController'; -import PlayerController from './PlayerController'; +import PlayerController from '../PlayerController'; describe('[T2] ConversationAreaController', () => { // A valid ConversationAreaController to be reused within the tests @@ -53,10 +53,11 @@ describe('[T2] ConversationAreaController', () => { testArea.occupants = newOccupants; expect(testArea.occupants).toEqual(newOccupants); expect(mockListeners.occupantsChange).toBeCalledWith(newOccupants); - expect(testArea.toConversationAreaModel()).toEqual({ + expect(testArea.toInteractableAreaModel()).toEqual({ id: testArea.id, topic: testArea.topic, - occupantsByID: testArea.occupants.map(eachOccupant => eachOccupant.id), + occupants: testArea.occupants.map(eachOccupant => eachOccupant.id), + type: 'ConversationArea', }); }); }); @@ -71,10 +72,11 @@ describe('[T2] ConversationAreaController', () => { testArea.topic = newTopic; expect(mockListeners.topicChange).toBeCalledWith(newTopic); expect(testArea.topic).toEqual(newTopic); - expect(testArea.toConversationAreaModel()).toEqual({ + expect(testArea.toInteractableAreaModel()).toEqual({ id: testArea.id, topic: newTopic, - occupantsByID: testArea.occupants.map(eachOccupant => eachOccupant.id), + occupants: testArea.occupants.map(eachOccupant => eachOccupant.id), + type: 'ConversationArea', }); }); }); diff --git a/frontend/src/classes/ConversationAreaController.ts b/frontend/src/classes/interactable/ConversationAreaController.ts similarity index 59% rename from frontend/src/classes/ConversationAreaController.ts rename to frontend/src/classes/interactable/ConversationAreaController.ts index f5db6899d..f8b55bcd4 100644 --- a/frontend/src/classes/ConversationAreaController.ts +++ b/frontend/src/classes/interactable/ConversationAreaController.ts @@ -1,17 +1,14 @@ -import EventEmitter from 'events'; -import _ from 'lodash'; import { useEffect, useState } from 'react'; -import TypedEmitter from 'typed-emitter'; -import { ConversationArea as ConversationAreaModel } from '../types/CoveyTownSocket'; -import PlayerController from './PlayerController'; +import { ConversationArea as ConversationAreaModel } from '../../types/CoveyTownSocket'; +import PlayerController from '../PlayerController'; +import InteractableAreaController, { BaseInteractableEventMap } from './InteractableAreaController'; /** * The events that the ConversationAreaController emits to subscribers. These events * are only ever emitted to local components (not to the townService). */ -export type ConversationAreaEvents = { +export type ConversationAreaEvents = BaseInteractableEventMap & { topicChange: (newTopic: string | undefined) => void; - occupantsChange: (newOccupants: PlayerController[]) => void; }; // The special string that will be displayed when a conversation area does not have a topic set @@ -21,11 +18,10 @@ export const NO_TOPIC_STRING = '(No topic)'; * implementing the logic to bridge between the townService's interpretation of conversation areas and the * frontend's. The ConversationAreaController emits events when the conversation area changes. */ -export default class ConversationAreaController extends (EventEmitter as new () => TypedEmitter) { - private _occupants: PlayerController[] = []; - - private _id: string; - +export default class ConversationAreaController extends InteractableAreaController< + ConversationAreaEvents, + ConversationAreaModel +> { private _topic?: string; /** @@ -34,34 +30,12 @@ export default class ConversationAreaController extends (EventEmitter as new () * @param topic */ constructor(id: string, topic?: string) { - super(); - this._id = id; + super(id); this._topic = topic; } - /** - * The ID of this conversation area (read only) - */ - get id() { - return this._id; - } - - /** - * The list of occupants in this conversation area. Changing the set of occupants - * will emit an occupantsChange event. - */ - set occupants(newOccupants: PlayerController[]) { - if ( - newOccupants.length !== this._occupants.length || - _.xor(newOccupants, this._occupants).length > 0 - ) { - this.emit('occupantsChange', newOccupants); - this._occupants = newOccupants; - } - } - - get occupants() { - return this._occupants; + public isActive(): boolean { + return this.topic !== undefined && this.occupants.length > 0; } /** @@ -80,22 +54,27 @@ export default class ConversationAreaController extends (EventEmitter as new () return this._topic; } + protected _updateFrom(newModel: ConversationAreaModel): void { + this.topic = newModel.topic; + } + /** * A conversation area is empty if there are no occupants in it, or the topic is undefined. */ isEmpty(): boolean { - return this._topic === undefined || this._occupants.length === 0; + return this._topic === undefined || this.occupants.length === 0; } /** * Return a representation of this ConversationAreaController that matches the * townService's representation and is suitable for transmitting over the network. */ - toConversationAreaModel(): ConversationAreaModel { + toInteractableAreaModel(): ConversationAreaModel { return { id: this.id, - occupantsByID: this.occupants.map(player => player.id), + occupants: this.occupants.map(player => player.id), topic: this.topic, + type: 'ConversationArea', }; } @@ -110,27 +89,11 @@ export default class ConversationAreaController extends (EventEmitter as new () playerFinder: (playerIDs: string[]) => PlayerController[], ): ConversationAreaController { const ret = new ConversationAreaController(convAreaModel.id, convAreaModel.topic); - ret.occupants = playerFinder(convAreaModel.occupantsByID); + ret.occupants = playerFinder(convAreaModel.occupants); return ret; } } -/** - * A react hook to retrieve the occupants of a ConversationAreaController, returning an array of PlayerController. - * - * This hook will re-render any components that use it when the set of occupants changes. - */ -export function useConversationAreaOccupants(area: ConversationAreaController): PlayerController[] { - const [occupants, setOccupants] = useState(area.occupants); - useEffect(() => { - area.addListener('occupantsChange', setOccupants); - return () => { - area.removeListener('occupantsChange', setOccupants); - }; - }, [area]); - return occupants; -} - /** * A react hook to retrieve the topic of a ConversationAreaController. * If there is currently no topic defined, it will return NO_TOPIC_STRING. diff --git a/frontend/src/classes/interactable/GameAreaController.ts b/frontend/src/classes/interactable/GameAreaController.ts new file mode 100644 index 000000000..2c92309a2 --- /dev/null +++ b/frontend/src/classes/interactable/GameAreaController.ts @@ -0,0 +1,123 @@ +import _ from 'lodash'; +import { + GameArea, + GameInstanceID, + GameResult, + GameState, + InteractableID, +} from '../../types/CoveyTownSocket'; +import PlayerController from '../PlayerController'; +import TownController from '../TownController'; +import InteractableAreaController, { BaseInteractableEventMap } from './InteractableAreaController'; + +export type GameEventTypes = BaseInteractableEventMap & { + gameStart: () => void; + gameUpdated: () => void; + gameEnd: () => void; + playersChange: (newPlayers: PlayerController[]) => void; +}; + +/** + * This class is the base class for all game controllers. It is responsible for managing the + * state of the game, and for sending commands to the server to update the state of the game. + * It is also responsible for notifying the UI when the state of the game changes, by emitting events. + */ +export default abstract class GameAreaController< + State extends GameState, + EventTypes extends GameEventTypes, +> extends InteractableAreaController> { + protected _instanceID?: GameInstanceID; + + protected _townController: TownController; + + protected _model: GameArea; + + protected _players: PlayerController[] = []; + + constructor(id: InteractableID, gameArea: GameArea, townController: TownController) { + super(id); + this._model = gameArea; + this._townController = townController; + + const game = gameArea.game; + if (game && game.players) + this._players = game.players.map(playerID => this._townController.getPlayer(playerID)); + } + + get history(): GameResult[] { + return this._model.history; + } + + get players(): PlayerController[] { + return this._players; + } + + public get observers(): PlayerController[] { + return this.occupants.filter(eachOccupant => !this._players.includes(eachOccupant)); + } + + /** + * Sends a request to the server to join the current game in the game area, or create a new one if there is no game in progress. + * + * @throws An error if the server rejects the request to join the game. + */ + public async joinGame() { + const { gameID } = await this._townController.sendInteractableCommand(this.id, { + type: 'JoinGame', + }); + this._instanceID = gameID; + } + + /** + * Sends a request to the server to leave the current game in the game area. + */ + public async leaveGame() { + const instanceID = this._instanceID; + if (instanceID) { + await this._townController.sendInteractableCommand(this.id, { + type: 'LeaveGame', + gameID: instanceID, + }); + } + } + + protected _updateFrom(newModel: GameArea): void { + const gameEnding = + this._model.game?.state.status === 'IN_PROGRESS' && newModel.game?.state.status === 'OVER'; + const newPlayers = + newModel.game?.players.map(playerID => this._townController.getPlayer(playerID)) ?? []; + if (!newPlayers && this._players.length > 0) { + this._players = []; + //TODO - Bounty for figuring out how to make the types work here + //eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + this.emit('playersChange', []); + } + if ( + this._players.length != newModel.game?.players.length || + _.xor(newPlayers, this._players).length > 0 + ) { + this._players = newPlayers; + //TODO - Bounty for figuring out how to make the types work here + //eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + this.emit('playersChange', newPlayers); + } + this._model = newModel; + //TODO - Bounty for figuring out how to make the types work here + //eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + this.emit('gameUpdated'); + this._instanceID = newModel.game?.id ?? this._instanceID; + if (gameEnding) { + //TODO - Bounty for figuring out how to make the types work here + //eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + this.emit('gameEnd'); + } + } + + toInteractableAreaModel(): GameArea { + return this._model; + } +} diff --git a/frontend/src/classes/interactable/InteractableAreaController.ts b/frontend/src/classes/interactable/InteractableAreaController.ts new file mode 100644 index 000000000..54e2901a1 --- /dev/null +++ b/frontend/src/classes/interactable/InteractableAreaController.ts @@ -0,0 +1,151 @@ +import _ from 'lodash'; +import { useEffect, useState } from 'react'; +import { EventMap } from 'typed-emitter'; +import { + Interactable as InteractableAreaModel, + InteractableID, + PlayerID, +} from '../../types/CoveyTownSocket'; +import PlayerController from '../PlayerController'; + +export type BaseInteractableEventMap = EventMap & { + occupantsChange: (newOccupants: PlayerController[]) => void; +}; + +/** + * A InteractableAreaController manages the local behavior of a interactable area in the frontend, + * implementing the logic to bridge between the townService's interpretation of interactable areas and the + * frontend's. The InteractableAreaController emits events when the interactable area changes. + */ +export default abstract class InteractableAreaController< + EmittedEventType extends BaseInteractableEventMap, + InteractableModelType extends InteractableAreaModel, +> { + private readonly _id: InteractableID; + + private _occupants: PlayerController[] = []; + + private _listeners: Map = + new Map(); + + constructor(id: InteractableID) { + this._id = id; + } + + get id() { + return this._id; + } + + /** + * Add a listener for an event emitted by this InteractableAreaController + * @param event + * @param listener + * @returns + */ + public addListener( + event: E, + listener: EmittedEventType[E], + ): this { + const listeners = this._listeners.get(event) ?? []; + listeners.push(listener); + this._listeners.set(event, listeners); + return this; + } + + /** + * Remove a listener for an event emitted by this InteractableAreaController + * @param event + * @param listener + * @returns + */ + public removeListener( + event: E, + listener: EmittedEventType[E], + ): this { + const listeners = this._listeners.get(event) ?? []; + _.remove(listeners, l => l === listener); + this._listeners.set(event, listeners); + return this; + } + + /** + * Emit an event to all listeners for that event + * @param event + * @param args + * @returns + */ + public emit( + event: E, + ...args: Parameters + ): boolean { + const listeners = this._listeners.get(event) ?? []; + listeners.forEach(listener => listener(...args)); + return true; + } + + public get occupantsByID(): PlayerID[] { + return this._occupants.map(eachOccupant => eachOccupant.id); + } + + public get occupants(): PlayerController[] { + return this._occupants; + } + + /** + * Set the occupants of this interactable area, emitting an event if the occupants change. + */ + public set occupants(newOccupants: PlayerController[]) { + if ( + newOccupants.length !== this._occupants.length || + _.xor(newOccupants, this._occupants).length > 0 + ) { + //TODO - Bounty for figuring out how to make the types work here + //eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + this.emit('occupantsChange', newOccupants); + this._occupants = newOccupants; + } + } + + abstract toInteractableAreaModel(): InteractableModelType; + + /** + * Update the state of this interactable area from a new interactable area model, and a list of occupants. + * @param newModel + * @param occupants + */ + updateFrom(newModel: InteractableModelType, occupants: PlayerController[]): void { + this.occupants = occupants; + this._updateFrom(newModel); + } + + /** + * Update the state of this interactable area from a new interactable area model. + * @param newModel + */ + protected abstract _updateFrom(newModel: InteractableModelType): void; + + public abstract isActive(): boolean; + + isEmpty(): boolean { + return this._occupants.length === 0; + } +} +/** + * A react hook to retrieve the occupants of a ConversationAreaController, returning an array of PlayerController. + * + * This hook will re-render any components that use it when the set of occupants changes. + */ +export function useInteractableAreaOccupants< + E extends BaseInteractableEventMap & EventMap, + T extends InteractableAreaModel, +>(area: InteractableAreaController): PlayerController[] { + const [occupants, setOccupants] = useState(area.occupants); + useEffect(() => { + area.addListener('occupantsChange', setOccupants); + return () => { + area.removeListener('occupantsChange', setOccupants); + }; + }, [area]); + return occupants; +} diff --git a/frontend/src/classes/interactable/TicTacToeAreaController.test.ts b/frontend/src/classes/interactable/TicTacToeAreaController.test.ts new file mode 100644 index 000000000..32e189ae6 --- /dev/null +++ b/frontend/src/classes/interactable/TicTacToeAreaController.test.ts @@ -0,0 +1,568 @@ +import assert from 'assert'; +import { mock } from 'jest-mock-extended'; +import { nanoid } from 'nanoid'; +import { + GameArea, + GameResult, + GameStatus, + TicTacToeGameState, + TicTacToeGridPosition, + TicTacToeMove, +} from '../../types/CoveyTownSocket'; +import PlayerController from '../PlayerController'; +import TownController from '../TownController'; +import GameAreaController from './GameAreaController'; +import TicTacToeAreaController, { NO_GAME_IN_PROGRESS_ERROR } from './TicTacToeAreaController'; + +describe('[T1] TicTacToeAreaController', () => { + const ourPlayer = new PlayerController(nanoid(), nanoid(), { + x: 0, + y: 0, + moving: false, + rotation: 'front', + }); + const otherPlayers = [ + new PlayerController(nanoid(), nanoid(), { x: 0, y: 0, moving: false, rotation: 'front' }), + new PlayerController(nanoid(), nanoid(), { x: 0, y: 0, moving: false, rotation: 'front' }), + ]; + + const mockTownController = mock(); + Object.defineProperty(mockTownController, 'ourPlayer', { + get: () => ourPlayer, + }); + Object.defineProperty(mockTownController, 'players', { + get: () => [ourPlayer, ...otherPlayers], + }); + mockTownController.getPlayer.mockImplementation(playerID => { + const p = mockTownController.players.find(player => player.id === playerID); + assert(p); + return p; + }); + + function ticTacToeAreaControllerWithProp({ + _id, + history, + x, + o, + undefinedGame, + status, + moves, + winner, + }: { + _id?: string; + history?: GameResult[]; + x?: string; + o?: string; + undefinedGame?: boolean; + status?: GameStatus; + moves?: TicTacToeMove[]; + winner?: string; + }) { + const id = _id || nanoid(); + const players = []; + if (x) players.push(x); + if (o) players.push(o); + const ret = new TicTacToeAreaController( + id, + { + id, + occupants: players, + history: history || [], + type: 'TicTacToeArea', + game: undefinedGame + ? undefined + : { + id, + players: players, + state: { + status: status || 'IN_PROGRESS', + x: x, + o: o, + moves: moves || [], + winner: winner, + }, + }, + }, + mockTownController, + ); + if (players) { + ret.occupants = players + .map(eachID => mockTownController.players.find(eachPlayer => eachPlayer.id === eachID)) + .filter(eachPlayer => eachPlayer) as PlayerController[]; + } + return ret; + } + describe('[T1.1]', () => { + describe('isActive', () => { + it('should return true if the game is in progress', () => { + const controller = ticTacToeAreaControllerWithProp({ + status: 'IN_PROGRESS', + }); + expect(controller.isActive()).toBe(true); + }); + it('should return false if the game is not in progress', () => { + const controller = ticTacToeAreaControllerWithProp({ + status: 'OVER', + }); + expect(controller.isActive()).toBe(false); + }); + }); + describe('isPlayer', () => { + it('should return true if the current player is a player in this game', () => { + const controller = ticTacToeAreaControllerWithProp({ + status: 'IN_PROGRESS', + x: ourPlayer.id, + }); + expect(controller.isPlayer).toBe(true); + }); + it('should return false if the current player is not a player in this game', () => { + const controller = ticTacToeAreaControllerWithProp({ + status: 'IN_PROGRESS', + x: otherPlayers[0].id, + o: otherPlayers[1].id, + }); + expect(controller.isPlayer).toBe(false); + }); + }); + describe('gamePiece', () => { + it('should return the game piece of the current player if the current player is a player in this game', () => { + const controller = ticTacToeAreaControllerWithProp({ + status: 'IN_PROGRESS', + x: ourPlayer.id, + }); + expect(controller.gamePiece).toBe('X'); + + //check O + const controller2 = ticTacToeAreaControllerWithProp({ + status: 'IN_PROGRESS', + o: ourPlayer.id, + }); + expect(controller2.gamePiece).toBe('O'); + }); + it('should throw an error if the current player is not a player in this game', () => { + const controller = ticTacToeAreaControllerWithProp({ + status: 'IN_PROGRESS', + x: otherPlayers[0].id, + o: otherPlayers[1].id, + }); + expect(() => controller.gamePiece).toThrowError(); + }); + }); + describe('status', () => { + it('should return the status of the game', () => { + const controller = ticTacToeAreaControllerWithProp({ + status: 'IN_PROGRESS', + }); + expect(controller.status).toBe('IN_PROGRESS'); + }); + it('should return WAITING_TO_START if the game is not defined', () => { + const controller = ticTacToeAreaControllerWithProp({ + undefinedGame: true, + }); + expect(controller.status).toBe('WAITING_TO_START'); + }); + }); + describe('whoseTurn', () => { + it('should return the player whose turn it is initially', () => { + const controller = ticTacToeAreaControllerWithProp({ + status: 'IN_PROGRESS', + x: ourPlayer.id, + o: otherPlayers[0].id, + }); + expect(controller.whoseTurn).toBe(ourPlayer); + }); + it('should return the player whose turn it is after a move', () => { + const controller = ticTacToeAreaControllerWithProp({ + status: 'IN_PROGRESS', + x: ourPlayer.id, + o: otherPlayers[0].id, + moves: [ + { + gamePiece: 'X', + row: 0, + col: 0, + }, + ], + }); + expect(controller.whoseTurn).toBe(otherPlayers[0]); + }); + it('should return undefined if the game is not in progress', () => { + const controller = ticTacToeAreaControllerWithProp({ + status: 'OVER', + x: ourPlayer.id, + o: otherPlayers[0].id, + }); + expect(controller.whoseTurn).toBe(undefined); + }); + }); + describe('isOurTurn', () => { + it('should return true if it is our turn', () => { + const controller = ticTacToeAreaControllerWithProp({ + status: 'IN_PROGRESS', + x: ourPlayer.id, + o: otherPlayers[0].id, + }); + expect(controller.isOurTurn).toBe(true); + }); + it('should return false if it is not our turn', () => { + const controller = ticTacToeAreaControllerWithProp({ + status: 'IN_PROGRESS', + x: otherPlayers[0].id, + o: ourPlayer.id, + }); + expect(controller.isOurTurn).toBe(false); + }); + }); + describe('moveCount', () => { + it('should return the number of moves that have been made', () => { + const controller = ticTacToeAreaControllerWithProp({ + status: 'IN_PROGRESS', + x: ourPlayer.id, + o: otherPlayers[0].id, + moves: [ + { + gamePiece: 'X', + row: 0, + col: 0, + }, + ], + }); + expect(controller.moveCount).toBe(1); + }); + }); + describe('board', () => { + it('should return an empty board by default', () => { + const controller = ticTacToeAreaControllerWithProp({ + status: 'IN_PROGRESS', + x: ourPlayer.id, + o: otherPlayers[0].id, + }); + expect(controller.board).toEqual([ + [undefined, undefined, undefined], + [undefined, undefined, undefined], + [undefined, undefined, undefined], + ]); + }); + }); + describe('x', () => { + it('should return the x player if there is one', () => { + const controller = ticTacToeAreaControllerWithProp({ + status: 'IN_PROGRESS', + x: ourPlayer.id, + o: otherPlayers[0].id, + }); + expect(controller.x).toBe(ourPlayer); + }); + it('should return undefined if there is no x player and the game is waiting to start', () => { + const controller = ticTacToeAreaControllerWithProp({ + status: 'WAITING_TO_START', + }); + expect(controller.x).toBe(undefined); + }); + it('should return undefined if there is no x player', () => { + const controller = ticTacToeAreaControllerWithProp({ + status: 'IN_PROGRESS', + o: otherPlayers[0].id, + }); + expect(controller.x).toBe(undefined); + }); + }); + describe('o', () => { + it('should return the o player if there is one', () => { + const controller = ticTacToeAreaControllerWithProp({ + status: 'IN_PROGRESS', + x: otherPlayers[0].id, + o: ourPlayer.id, + }); + expect(controller.o).toBe(ourPlayer); + }); + it('should return undefined if there is no o player and the game is waiting to start', () => { + const controller = ticTacToeAreaControllerWithProp({ + status: 'WAITING_TO_START', + }); + expect(controller.o).toBe(undefined); + }); + it('should return undefined if there is no o player', () => { + const controller = ticTacToeAreaControllerWithProp({ + status: 'IN_PROGRESS', + x: otherPlayers[0].id, + }); + expect(controller.o).toBe(undefined); + }); + }); + describe('winner', () => { + it('should return the winner if there is one', () => { + const controller = ticTacToeAreaControllerWithProp({ + status: 'OVER', + x: otherPlayers[0].id, + o: ourPlayer.id, + winner: ourPlayer.id, + }); + expect(controller.winner).toBe(ourPlayer); + }); + it('should return undefined if there is no winner', () => { + const controller = ticTacToeAreaControllerWithProp({ + status: 'OVER', + x: otherPlayers[0].id, + o: ourPlayer.id, + }); + expect(controller.winner).toBe(undefined); + }); + }); + describe('makeMove', () => { + it('should throw an error if the game is not in progress', async () => { + const controller = ticTacToeAreaControllerWithProp({}); + await expect(async () => controller.makeMove(0, 0)).rejects.toEqual( + new Error(NO_GAME_IN_PROGRESS_ERROR), + ); + }); + it('Should call townController.sendInteractableCommand', async () => { + const controller = ticTacToeAreaControllerWithProp({ + status: 'IN_PROGRESS', + x: ourPlayer.id, + o: otherPlayers[0].id, + }); + // Simulate joining the game for real + const instanceID = nanoid(); + mockTownController.sendInteractableCommand.mockImplementationOnce(async () => { + return { gameID: instanceID }; + }); + await controller.joinGame(); + mockTownController.sendInteractableCommand.mockReset(); + await controller.makeMove(2, 1); + expect(mockTownController.sendInteractableCommand).toHaveBeenCalledWith(controller.id, { + type: 'GameMove', + gameID: instanceID, + move: { + row: 2, + col: 1, + gamePiece: 'X', + }, + }); + }); + }); + }); + describe('[T1.2] _updateFrom', () => { + describe('if the game is in progress', () => { + let controller: TicTacToeAreaController; + beforeEach(() => { + controller = ticTacToeAreaControllerWithProp({ + status: 'IN_PROGRESS', + x: ourPlayer.id, + o: otherPlayers[0].id, + }); + }); + it('should emit a boardChanged event with the new board', () => { + const model = controller.toInteractableAreaModel(); + const newMoves: ReadonlyArray = [ + { + gamePiece: 'X', + row: 0 as TicTacToeGridPosition, + col: 0 as TicTacToeGridPosition, + }, + { + gamePiece: 'O', + row: 1 as TicTacToeGridPosition, + col: 1 as TicTacToeGridPosition, + }, + ]; + assert(model.game); + const newModel: GameArea = { + ...model, + game: { + ...model.game, + state: { + ...model.game?.state, + moves: newMoves, + }, + }, + }; + const emitSpy = jest.spyOn(controller, 'emit'); + controller.updateFrom(newModel, otherPlayers.concat(ourPlayer)); + const boardChangedCall = emitSpy.mock.calls.find(call => call[0] === 'boardChanged'); + expect(boardChangedCall).toBeDefined(); + if (boardChangedCall) + expect(boardChangedCall[1]).toEqual([ + ['X', undefined, undefined], + [undefined, 'O', undefined], + [undefined, undefined, undefined], + ]); + }); + it('should emit a turnChanged event with true if it is our turn', () => { + const model = controller.toInteractableAreaModel(); + const newMoves: ReadonlyArray = [ + { + gamePiece: 'X', + row: 0 as TicTacToeGridPosition, + col: 0 as TicTacToeGridPosition, + }, + { + gamePiece: 'O', + row: 1 as TicTacToeGridPosition, + col: 1 as TicTacToeGridPosition, + }, + ]; + assert(model.game); + const newModel: GameArea = { + ...model, + game: { + ...model.game, + state: { + ...model.game?.state, + moves: [newMoves[0]], + }, + }, + }; + controller.updateFrom(newModel, otherPlayers.concat(ourPlayer)); + const testModel: GameArea = { + ...model, + game: { + ...model.game, + state: { + ...model.game?.state, + moves: newMoves, + }, + }, + }; + const emitSpy = jest.spyOn(controller, 'emit'); + controller.updateFrom(testModel, otherPlayers.concat(ourPlayer)); + const turnChangedCall = emitSpy.mock.calls.find(call => call[0] === 'turnChanged'); + expect(turnChangedCall).toBeDefined(); + if (turnChangedCall) expect(turnChangedCall[1]).toEqual(true); + }); + it('should emit a turnChanged event with false if it is not our turn', () => { + const model = controller.toInteractableAreaModel(); + const newMoves: ReadonlyArray = [ + { + gamePiece: 'X', + row: 0 as TicTacToeGridPosition, + col: 0 as TicTacToeGridPosition, + }, + ]; + expect(controller.isOurTurn).toBe(true); + assert(model.game); + const newModel: GameArea = { + ...model, + game: { + ...model.game, + state: { + ...model.game?.state, + moves: newMoves, + }, + }, + }; + const emitSpy = jest.spyOn(controller, 'emit'); + controller.updateFrom(newModel, otherPlayers.concat(ourPlayer)); + const turnChangedCall = emitSpy.mock.calls.find(call => call[0] === 'turnChanged'); + expect(turnChangedCall).toBeDefined(); + if (turnChangedCall) expect(turnChangedCall[1]).toEqual(false); + }); + it('should not emit a turnChanged event if the turn has not changed', () => { + const model = controller.toInteractableAreaModel(); + assert(model.game); + expect(controller.isOurTurn).toBe(true); + const emitSpy = jest.spyOn(controller, 'emit'); + controller.updateFrom(model, otherPlayers.concat(ourPlayer)); + const turnChangedCall = emitSpy.mock.calls.find(call => call[0] === 'turnChanged'); + expect(turnChangedCall).not.toBeDefined(); + }); + it('should not emit a boardChanged event if the board has not changed', () => { + const model = controller.toInteractableAreaModel(); + assert(model.game); + + const newMoves: ReadonlyArray = [ + { + gamePiece: 'X', + row: 0 as TicTacToeGridPosition, + col: 0 as TicTacToeGridPosition, + }, + { + gamePiece: 'O', + row: 1 as TicTacToeGridPosition, + col: 1 as TicTacToeGridPosition, + }, + ]; + const newModel: GameArea = { + ...model, + game: { + ...model.game, + state: { + ...model.game?.state, + moves: newMoves, + }, + }, + }; + controller.updateFrom(newModel, otherPlayers.concat(ourPlayer)); + + const newMovesWithShuffle: ReadonlyArray = [ + { + gamePiece: 'O', + row: 1 as TicTacToeGridPosition, + col: 1 as TicTacToeGridPosition, + }, + { + gamePiece: 'X', + row: 0 as TicTacToeGridPosition, + col: 0 as TicTacToeGridPosition, + }, + ]; + + const newModelWithSuffle: GameArea = { + ...model, + game: { + ...model.game, + state: { + ...model.game?.state, + moves: newMovesWithShuffle, + }, + }, + }; + const emitSpy = jest.spyOn(controller, 'emit'); + controller.updateFrom(newModelWithSuffle, otherPlayers.concat(ourPlayer)); + const turnChangedCall = emitSpy.mock.calls.find(call => call[0] === 'boardChanged'); + expect(turnChangedCall).not.toBeDefined(); + }); + it('should update the board returned by the board property', () => { + const model = controller.toInteractableAreaModel(); + const newMoves: ReadonlyArray = [ + { + gamePiece: 'X', + row: 0 as TicTacToeGridPosition, + col: 0 as TicTacToeGridPosition, + }, + { + gamePiece: 'O', + row: 1 as TicTacToeGridPosition, + col: 1 as TicTacToeGridPosition, + }, + ]; + assert(model.game); + const newModel: GameArea = { + ...model, + game: { + ...model.game, + state: { + ...model.game?.state, + moves: newMoves, + }, + }, + }; + controller.updateFrom(newModel, otherPlayers.concat(ourPlayer)); + expect(controller.board).toEqual([ + ['X', undefined, undefined], + [undefined, 'O', undefined], + [undefined, undefined, undefined], + ]); + }); + }); + it('should call super._updateFrom', () => { + //eslint-disable-next-line @typescript-eslint/ban-ts-comment + //@ts-ignore - we are testing spying on a private method + const spy = jest.spyOn(GameAreaController.prototype, '_updateFrom'); + const controller = ticTacToeAreaControllerWithProp({}); + const model = controller.toInteractableAreaModel(); + controller.updateFrom(model, otherPlayers.concat(ourPlayer)); + expect(spy).toHaveBeenCalled(); + }); + }); +}); diff --git a/frontend/src/classes/interactable/TicTacToeAreaController.ts b/frontend/src/classes/interactable/TicTacToeAreaController.ts new file mode 100644 index 000000000..c6f8c3a82 --- /dev/null +++ b/frontend/src/classes/interactable/TicTacToeAreaController.ts @@ -0,0 +1,206 @@ +import _ from 'lodash'; +import { + GameArea, + GameStatus, + TicTacToeGameState, + TicTacToeGridPosition, +} from '../../types/CoveyTownSocket'; +import PlayerController from '../PlayerController'; +import GameAreaController, { GameEventTypes } from './GameAreaController'; + +export const PLAYER_NOT_IN_GAME_ERROR = 'Player is not in game'; + +export const NO_GAME_IN_PROGRESS_ERROR = 'No game in progress'; + +export type TicTacToeCell = 'X' | 'O' | undefined; +export type TicTacToeEvents = GameEventTypes & { + boardChanged: (board: TicTacToeCell[][]) => void; + turnChanged: (isOurTurn: boolean) => void; +}; + +/** + * This class is responsible for managing the state of the Tic Tac Toe game, and for sending commands to the server + */ +export default class TicTacToeAreaController extends GameAreaController< + TicTacToeGameState, + TicTacToeEvents +> { + protected _board: TicTacToeCell[][] = [ + [undefined, undefined, undefined], + [undefined, undefined, undefined], + [undefined, undefined, undefined], + ]; + + /** + * Returns the current state of the board. + * + * The board is a 3x3 array of TicTacToeCell, which is either 'X', 'O', or undefined. + * + * The 2-dimensional array is indexed by row and then column, so board[0][0] is the top-left cell, + * and board[2][2] is the bottom-right cell + */ + get board(): TicTacToeCell[][] { + return this._board; + } + + /** + * Returns the player with the 'X' game piece, if there is one, or undefined otherwise + */ + get x(): PlayerController | undefined { + const x = this._model.game?.state.x; + if (x) { + return this.occupants.find(eachOccupant => eachOccupant.id === x); + } + return undefined; + } + + /** + * Returns the player with the 'O' game piece, if there is one, or undefined otherwise + */ + get o(): PlayerController | undefined { + const o = this._model.game?.state.o; + if (o) { + return this.occupants.find(eachOccupant => eachOccupant.id === o); + } + return undefined; + } + + /** + * Returns the number of moves that have been made in the game + */ + get moveCount(): number { + return this._model.game?.state.moves.length || 0; + } + + /** + * Returns the winner of the game, if there is one + */ + get winner(): PlayerController | undefined { + const winner = this._model.game?.state.winner; + if (winner) { + return this.occupants.find(eachOccupant => eachOccupant.id === winner); + } + return undefined; + } + + /** + * Returns the player whose turn it is, if the game is in progress + * Returns undefined if the game is not in progress + */ + get whoseTurn(): PlayerController | undefined { + const x = this.x; + const o = this.o; + if (!x || !o || this._model.game?.state.status !== 'IN_PROGRESS') { + return undefined; + } + if (this.moveCount % 2 === 0) { + return x; + } else if (this.moveCount % 2 === 1) { + return o; + } else { + throw new Error('Invalid move count'); + } + } + + get isOurTurn(): boolean { + return this.whoseTurn?.id === this._townController.ourPlayer.id; + } + + /** + * Returns true if the current player is a player in this game + */ + get isPlayer(): boolean { + return this._model.game?.players.includes(this._townController.ourPlayer.id) || false; + } + + /** + * Returns the game piece of the current player, if the current player is a player in this game + * + * Throws an error PLAYER_NOT_IN_GAME_ERROR if the current player is not a player in this game + */ + get gamePiece(): 'X' | 'O' { + if (this.x?.id === this._townController.ourPlayer.id) { + return 'X'; + } else if (this.o?.id === this._townController.ourPlayer.id) { + return 'O'; + } + throw new Error(PLAYER_NOT_IN_GAME_ERROR); + } + + /** + * Returns the status of the game. + * Defaults to 'WAITING_TO_START' if the game is not in progress + */ + get status(): GameStatus { + const status = this._model.game?.state.status; + if (!status) { + return 'WAITING_TO_START'; + } + return status; + } + + /** + * Returns true if the game is in progress + */ + public isActive(): boolean { + return this._model.game?.state.status === 'IN_PROGRESS'; + } + + /** + * Updates the internal state of this TicTacToeAreaController to match the new model. + * + * Calls super._updateFrom, which updates the occupants of this game area and + * other common properties (including this._model). + * + * If the board has changed, emits a 'boardChanged' event with the new board. If the board has not changed, + * does not emit the event. + * + * If the turn has changed, emits a 'turnChanged' event with true if it is our turn, and false otherwise. + * If the turn has not changed, does not emit the event. + */ + protected _updateFrom(newModel: GameArea): void { + const wasOurTurn = this.whoseTurn?.id === this._townController.ourPlayer.id; + super._updateFrom(newModel); + const newState = newModel.game; + if (newState) { + const newBoard: TicTacToeCell[][] = [ + [undefined, undefined, undefined], + [undefined, undefined, undefined], + [undefined, undefined, undefined], + ]; + newState.state.moves.forEach(move => { + newBoard[move.row][move.col] = move.gamePiece; + }); + if (!_.isEqual(newBoard, this._board)) { + this._board = newBoard; + this.emit('boardChanged', this._board); + } + } + const isOurTurn = this.whoseTurn?.id === this._townController.ourPlayer.id; + if (wasOurTurn != isOurTurn) this.emit('turnChanged', isOurTurn); + } + + /** + * Sends a request to the server to make a move in the game + * + * If the game is not in progress, throws an error NO_GAME_IN_PROGRESS_ERROR + * + * @param row Row of the move + * @param col Column of the move + */ + public async makeMove(row: TicTacToeGridPosition, col: TicTacToeGridPosition) { + const instanceID = this._instanceID; + if (!instanceID || this._model.game?.state.status !== 'IN_PROGRESS') { + throw new Error(NO_GAME_IN_PROGRESS_ERROR); + } + await this._townController.sendInteractableCommand(this.id, { + type: 'GameMove', + gameID: instanceID, + move: { + row, + col, + gamePiece: this.gamePiece, + }, + }); + } +} diff --git a/frontend/src/classes/ViewingAreaController.test.ts b/frontend/src/classes/interactable/ViewingAreaController.test.ts similarity index 91% rename from frontend/src/classes/ViewingAreaController.test.ts rename to frontend/src/classes/interactable/ViewingAreaController.test.ts index 2e9bd849d..5277c522c 100644 --- a/frontend/src/classes/ViewingAreaController.test.ts +++ b/frontend/src/classes/interactable/ViewingAreaController.test.ts @@ -1,7 +1,7 @@ import { mock, mockClear, MockProxy } from 'jest-mock-extended'; import { nanoid } from 'nanoid'; -import { ViewingArea } from '../generated/client'; -import TownController from './TownController'; +import { ViewingArea } from '../../types/CoveyTownSocket'; +import TownController from '../TownController'; import ViewingAreaController, { ViewingAreaEvents } from './ViewingAreaController'; describe('[T2] ViewingAreaController', () => { @@ -16,6 +16,8 @@ describe('[T2] ViewingAreaController', () => { isPlaying: true, elapsedTimeSec: 12, video: nanoid(), + occupants: [], + type: 'ViewingArea', }; testArea = new ViewingAreaController(testAreaModel); mockClear(townController); @@ -65,7 +67,7 @@ describe('[T2] ViewingAreaController', () => { }); describe('viewingAreaModel', () => { it('Carries through all of the properties', () => { - const model = testArea.viewingAreaModel(); + const model = testArea.toInteractableAreaModel(); expect(model).toEqual(testAreaModel); }); }); @@ -76,8 +78,10 @@ describe('[T2] ViewingAreaController', () => { video: nanoid(), elapsedTimeSec: testArea.elapsedTimeSec + 1, isPlaying: !testArea.isPlaying, + occupants: [], + type: 'ViewingArea', }; - testArea.updateFrom(newModel); + testArea.updateFrom(newModel, []); expect(testArea.video).toEqual(newModel.video); expect(testArea.elapsedTimeSec).toEqual(newModel.elapsedTimeSec); expect(testArea.isPlaying).toEqual(newModel.isPlaying); @@ -92,8 +96,10 @@ describe('[T2] ViewingAreaController', () => { video: nanoid(), elapsedTimeSec: testArea.elapsedTimeSec + 1, isPlaying: !testArea.isPlaying, + occupants: [], + type: 'ViewingArea', }; - testArea.updateFrom(newModel); + testArea.updateFrom(newModel, []); expect(testArea.id).toEqual(existingID); }); }); diff --git a/frontend/src/classes/ViewingAreaController.ts b/frontend/src/classes/interactable/ViewingAreaController.ts similarity index 84% rename from frontend/src/classes/ViewingAreaController.ts rename to frontend/src/classes/interactable/ViewingAreaController.ts index 13794b568..5b07ff87a 100644 --- a/frontend/src/classes/ViewingAreaController.ts +++ b/frontend/src/classes/interactable/ViewingAreaController.ts @@ -1,11 +1,10 @@ -import { EventEmitter } from 'events'; -import TypedEventEmitter from 'typed-emitter'; -import { ViewingArea as ViewingAreaModel } from '../types/CoveyTownSocket'; +import { ViewingArea as ViewingAreaModel } from '../../types/CoveyTownSocket'; +import InteractableAreaController, { BaseInteractableEventMap } from './InteractableAreaController'; /** * The events that a ViewingAreaController can emit */ -export type ViewingAreaEvents = { +export type ViewingAreaEvents = BaseInteractableEventMap & { /** * A playbackChange event indicates that the playing/paused state has changed. * Listeners are passed the new state in the parameter `isPlaying` @@ -33,7 +32,10 @@ export type ViewingAreaEvents = { * The ViewingAreaController implements callbacks that handle events from the video player in this browser window, and * emits updates when the state is updated, @see ViewingAreaEvents */ -export default class ViewingAreaController extends (EventEmitter as new () => TypedEventEmitter) { +export default class ViewingAreaController extends InteractableAreaController< + ViewingAreaEvents, + ViewingAreaModel +> { private _model: ViewingAreaModel; /** @@ -43,17 +45,12 @@ export default class ViewingAreaController extends (EventEmitter as new () => Ty * @param viewingAreaModel The viewing area model that this controller should represent */ constructor(viewingAreaModel: ViewingAreaModel) { - super(); + super(viewingAreaModel.id); this._model = viewingAreaModel; } - /** - * The ID of the viewing area represented by this viewing area controller - * This property is read-only: once a ViewingAreaController is created, it will always be - * tied to the same viewing area ID. - */ - public get id() { - return this._model.id; + public isActive(): boolean { + return this._model.video !== undefined; } /** @@ -118,7 +115,7 @@ export default class ViewingAreaController extends (EventEmitter as new () => Ty /** * @returns ViewingAreaModel that represents the current state of this ViewingAreaController */ - public viewingAreaModel(): ViewingAreaModel { + public toInteractableAreaModel(): ViewingAreaModel { return this._model; } @@ -128,7 +125,7 @@ export default class ViewingAreaController extends (EventEmitter as new () => Ty * * @param updatedModel */ - public updateFrom(updatedModel: ViewingAreaModel): void { + protected _updateFrom(updatedModel: ViewingAreaModel): void { this.isPlaying = updatedModel.isPlaying; this.elapsedTimeSec = updatedModel.elapsedTimeSec; this.video = updatedModel.video; diff --git a/frontend/src/components/Login/TownSelection.test.tsx b/frontend/src/components/Login/TownSelection.test.tsx index 38059b18a..dd9b1baec 100644 --- a/frontend/src/components/Login/TownSelection.test.tsx +++ b/frontend/src/components/Login/TownSelection.test.tsx @@ -222,7 +222,6 @@ describe('Town Selection', () => { let rows = renderData.getAllByRole('row'); for (let i = 1; i < rows.length; i += 1) { // off-by-one for the header row - // console.log(rows[i]); const existing = within(rows[i]).getByText(expectedTowns1[i - 1].friendlyName); expect(existing).toBeInTheDocument(); for (let j = 0; j < expectedTowns1.length; j += 1) { diff --git a/frontend/src/components/Login/TownSelection.tsx b/frontend/src/components/Login/TownSelection.tsx index 01c89f146..20d18d9a2 100644 --- a/frontend/src/components/Login/TownSelection.tsx +++ b/frontend/src/components/Login/TownSelection.tsx @@ -16,6 +16,7 @@ import { Td, Th, Thead, + ToastId, Tr, useToast, } from '@chakra-ui/react'; @@ -30,6 +31,7 @@ export default function TownSelection(): JSX.Element { const [newTownIsPublic, setNewTownIsPublic] = useState(true); const [townIDToJoin, setTownIDToJoin] = useState(''); const [currentPublicTowns, setCurrentPublicTowns] = useState(); + const [isJoining, setIsJoining] = useState(false); const loginController = useLoginController(); const { setTownController, townsService } = loginController; const { connect: videoConnect } = useVideoContext(); @@ -40,7 +42,7 @@ export default function TownSelection(): JSX.Element { townsService.listTowns().then(towns => { setCurrentPublicTowns(towns.sort((a, b) => b.currentOccupancy - a.currentOccupancy)); }); - }, [setCurrentPublicTowns, townsService]); + }, [townsService]); useEffect(() => { updateTownListings(); const timer = setInterval(updateTownListings, 2000); @@ -51,6 +53,8 @@ export default function TownSelection(): JSX.Element { const handleJoin = useCallback( async (coveyRoomID: string) => { + let connectWatchdog: NodeJS.Timeout | undefined = undefined; + let loadingToast: ToastId | undefined = undefined; try { if (!userName || userName.length === 0) { toast({ @@ -68,6 +72,29 @@ export default function TownSelection(): JSX.Element { }); return; } + const isHighLatencyTownService = + process.env.NEXT_PUBLIC_TOWNS_SERVICE_URL?.includes('onrender.com'); + connectWatchdog = setTimeout(() => { + if (isHighLatencyTownService) { + loadingToast = toast({ + title: 'Please be patient...', + description: + "The TownService is starting up - this may take 15-30 seconds, because it is hosted on a free Render.com service. Render.com's free tier automatically puts the TownService to sleep when it is inactive for 15 minutes.", + status: 'info', + isClosable: false, + duration: null, + }); + } else { + loadingToast = toast({ + title: 'Connecting to town...', + description: 'This is taking a bit longer than normal - please be patient...', + status: 'info', + isClosable: false, + duration: null, + }); + } + }, 1000); + setIsJoining(true); const newController = new TownController({ userName, townID: coveyRoomID, @@ -77,8 +104,20 @@ export default function TownSelection(): JSX.Element { const videoToken = newController.providerVideoToken; assert(videoToken); await videoConnect(videoToken); + setIsJoining(false); + if (loadingToast) { + toast.close(loadingToast); + } + clearTimeout(connectWatchdog); setTownController(newController); } catch (err) { + setIsJoining(false); + if (loadingToast) { + toast.close(loadingToast); + } + if (connectWatchdog) { + clearTimeout(connectWatchdog); + } if (err instanceof Error) { toast({ title: 'Unable to connect to Towns Service', @@ -114,11 +153,40 @@ export default function TownSelection(): JSX.Element { }); return; } + const isHighLatencyTownService = + process.env.NEXT_PUBLIC_TOWNS_SERVICE_URL?.includes('onrender.com'); + let loadingToast: ToastId | undefined = undefined; + const connectWatchdog = setTimeout(() => { + if (isHighLatencyTownService) { + loadingToast = toast({ + title: 'Please be patient...', + description: + "The TownService is starting up - this may take 15-30 seconds, because it is hosted on a free Render.com service. Render.com's free tier automatically puts the TownService to sleep when it is inactive for 15 minutes.", + status: 'info', + isClosable: false, + duration: null, + }); + } else { + loadingToast = toast({ + title: 'Connecting to town...', + description: 'This is taking a bit longer than normal - please be patient...', + status: 'info', + isClosable: false, + duration: null, + }); + } + }, 2000); + setIsJoining(true); try { const newTownInfo = await townsService.createTown({ friendlyName: newTownName, isPubliclyListed: newTownIsPublic, }); + clearTimeout(connectWatchdog); + setIsJoining(false); + if (loadingToast) { + toast.close(loadingToast); + } let privateMessage = <>; if (!newTownIsPublic) { privateMessage = ( @@ -145,6 +213,11 @@ export default function TownSelection(): JSX.Element { }); await handleJoin(newTownInfo.townID); } catch (err) { + clearTimeout(connectWatchdog); + setIsJoining(false); + if (loadingToast) { + toast.close(loadingToast); + } if (err instanceof Error) { toast({ title: 'Unable to connect to Towns Service', @@ -211,7 +284,11 @@ export default function TownSelection(): JSX.Element { - @@ -236,7 +313,11 @@ export default function TownSelection(): JSX.Element { onChange={event => setTownIDToJoin(event.target.value)} /> - @@ -264,7 +345,8 @@ export default function TownSelection(): JSX.Element { {town.currentOccupancy}/{town.maximumOccupancy} diff --git a/frontend/src/components/SocialSidebar/ConversationAreasList.test.tsx b/frontend/src/components/SocialSidebar/ConversationAreasList.test.tsx index b95b3ac06..cd7b67232 100644 --- a/frontend/src/components/SocialSidebar/ConversationAreasList.test.tsx +++ b/frontend/src/components/SocialSidebar/ConversationAreasList.test.tsx @@ -4,13 +4,13 @@ import { findAllByRole, render, RenderResult, waitFor } from '@testing-library/r import { nanoid } from 'nanoid'; import React from 'react'; import { act } from 'react-dom/test-utils'; -import ConversationAreaController from '../../classes/ConversationAreaController'; +import ConversationAreaController from '../../classes/interactable/ConversationAreaController'; import PlayerController from '../../classes/PlayerController'; import ConversationAreasList from './ConversationAreasList'; import TownController from '../../classes/TownController'; import { LoginController } from '../../contexts/LoginControllerContext'; import { mock, mockClear } from 'jest-mock-extended'; -import { BoundingBox, CoveyTownSocket } from '../../types/CoveyTownSocket'; +import { BoundingBox, ConversationArea, CoveyTownSocket } from '../../types/CoveyTownSocket'; import { getEventListener, mockTownControllerConnection } from '../../TestUtils'; import TownControllerContext from '../../contexts/TownControllerContext'; @@ -167,7 +167,7 @@ describe('ConversationAreasList', () => { areasToRender = areas; } await mockTownControllerConnection(testController, mockSocket, { - interactables: areasToRender.map(eachArea => eachArea.toConversationAreaModel()), + interactables: areasToRender.map(eachArea => eachArea.toInteractableAreaModel()), currentPlayers: allPlayers.map(eachPlayer => eachPlayer.toPlayerModel()), friendlyName: nanoid(), isPubliclyListed: true, @@ -265,7 +265,7 @@ describe('ConversationAreasList', () => { for (const eachArea of areas) { act(() => { eachArea.topic = undefined; - listener(eachArea.toConversationAreaModel()); + listener(eachArea.toInteractableAreaModel()); }); await waitFor(() => expect(renderData.queryAllByText(eachArea.topic || 'fail', { exact: false })).toEqual([]), @@ -281,9 +281,9 @@ describe('ConversationAreasList', () => { act(() => { listener({ id: areas[0].id, - occupantsByID: areas[0].occupants.map(eachOccupant => eachOccupant.id), + occupants: areas[0].occupants.map(eachOccupant => eachOccupant.id), topic: newTopic, - }); + } as ConversationArea); }); await waitFor(() => renderData.getAllByText(newTopic, { exact: false })); diff --git a/frontend/src/components/SocialSidebar/ConversationAreasList.tsx b/frontend/src/components/SocialSidebar/ConversationAreasList.tsx index ae420071c..01b4ed821 100644 --- a/frontend/src/components/SocialSidebar/ConversationAreasList.tsx +++ b/frontend/src/components/SocialSidebar/ConversationAreasList.tsx @@ -1,9 +1,9 @@ import { Box, Heading, ListItem, UnorderedList } from '@chakra-ui/react'; import React from 'react'; import ConversationAreaController, { - useConversationAreaOccupants, useConversationAreaTopic, -} from '../../classes/ConversationAreaController'; +} from '../../classes/interactable/ConversationAreaController'; +import { useInteractableAreaOccupants } from '../../classes/interactable/InteractableAreaController'; import { useActiveConversationAreas } from '../../classes/TownController'; import PlayerName from './PlayerName'; @@ -21,7 +21,7 @@ type ConversationAreaViewProps = { * See relevant hooks: useConversationAreas, usePlayersInTown. */ function ConversationAreaView({ area }: ConversationAreaViewProps): JSX.Element { - const occupants = useConversationAreaOccupants(area); + const occupants = useInteractableAreaOccupants(area); const topic = useConversationAreaTopic(area); return ( diff --git a/frontend/src/components/Town/Interactable.ts b/frontend/src/components/Town/Interactable.ts index fa4f04f15..552d5f149 100644 --- a/frontend/src/components/Town/Interactable.ts +++ b/frontend/src/components/Town/Interactable.ts @@ -2,7 +2,11 @@ import TownController from '../../classes/TownController'; import TownGameScene from './TownGameScene'; //TODO is there not some way to figure this out from generic types/supertypes? -export type KnownInteractableTypes = 'conversationArea' | 'viewingArea' | 'transporter'; +export type KnownInteractableTypes = + | 'conversationArea' + | 'viewingArea' + | 'transporter' + | 'gameArea'; /** * A base abstract class for representing an "interactable" in the Phaser game world. @@ -57,6 +61,7 @@ export default abstract class Interactable extends Phaser.GameObjects.Sprite { */ addedToScene(): void { super.addedToScene(); + this.townController = (this.scene as TownGameScene).coveyTownController; this._id = this.name; const sprite = this.townController.ourPlayer.gameObjects?.sprite; if (!sprite) { diff --git a/frontend/src/components/Town/TownGameScene.ts b/frontend/src/components/Town/TownGameScene.ts index 3040abd78..5a70b1f62 100644 --- a/frontend/src/components/Town/TownGameScene.ts +++ b/frontend/src/components/Town/TownGameScene.ts @@ -1,11 +1,12 @@ import assert from 'assert'; import Phaser from 'phaser'; -import PlayerController from '../../classes/PlayerController'; +import PlayerController, { MOVEMENT_SPEED } from '../../classes/PlayerController'; import TownController from '../../classes/TownController'; import { PlayerLocation } from '../../types/CoveyTownSocket'; import { Callback } from '../VideoCall/VideoFrontend/types'; import Interactable from './Interactable'; import ConversationArea from './interactables/ConversationArea'; +import GameArea from './interactables/GameArea'; import Transporter from './interactables/Transporter'; import ViewingArea from './interactables/ViewingArea'; @@ -16,8 +17,10 @@ function interactableTypeForObjectType(type: string): any { return ConversationArea; } else if (type === 'Transporter') { return Transporter; - } else if (type == 'ViewingArea') { + } else if (type === 'ViewingArea') { return ViewingArea; + } else if (type === 'GameArea') { + return GameArea; } else { throw new Error(`Unknown object type: ${type}`); } @@ -25,7 +28,6 @@ function interactableTypeForObjectType(type: string): any { // Original inspiration and code from: // https://medium.com/@michaelwesthadley/modular-game-worlds-in-phaser-3-tilemaps-1-958fc7e6bbd6 - export default class TownGameScene extends Phaser.Scene { private _pendingOverlapExits = new Map void>(); @@ -58,6 +60,11 @@ export default class TownGameScene extends Phaser.Scene { private _onGameReadyListeners: Callback[] = []; + /** + * Layers that the player can collide with. + */ + private _collidingLayers: Phaser.Tilemaps.TilemapLayer[] = []; + private _gameIsReady = new Promise(resolve => { if (this._ready) { resolve(); @@ -197,8 +204,6 @@ export default class TownGameScene extends Phaser.Scene { } const gameObjects = this.coveyTownController.ourPlayer.gameObjects; if (gameObjects && this._cursors) { - const speed = 175; - const prevVelocity = gameObjects.sprite.body.velocity.clone(); const body = gameObjects.sprite.body as Phaser.Physics.Arcade.Body; @@ -208,19 +213,19 @@ export default class TownGameScene extends Phaser.Scene { const primaryDirection = this.getNewMovementDirection(); switch (primaryDirection) { case 'left': - body.setVelocityX(-speed); + body.setVelocityX(-MOVEMENT_SPEED); gameObjects.sprite.anims.play('misa-left-walk', true); break; case 'right': - body.setVelocityX(speed); + body.setVelocityX(MOVEMENT_SPEED); gameObjects.sprite.anims.play('misa-right-walk', true); break; case 'front': - body.setVelocityY(speed); + body.setVelocityY(MOVEMENT_SPEED); gameObjects.sprite.anims.play('misa-front-walk', true); break; case 'back': - body.setVelocityY(-speed); + body.setVelocityY(-MOVEMENT_SPEED); gameObjects.sprite.anims.play('misa-back-walk', true); break; default: @@ -238,7 +243,7 @@ export default class TownGameScene extends Phaser.Scene { } // Normalize and scale the velocity so that player can't move faster along a diagonal - gameObjects.sprite.body.velocity.normalize().scale(speed); + gameObjects.sprite.body.velocity.normalize().scale(MOVEMENT_SPEED); const isMoving = primaryDirection !== undefined; gameObjects.label.setX(body.x); @@ -248,8 +253,6 @@ export default class TownGameScene extends Phaser.Scene { //Move the sprite if ( !this._lastLocation || - this._lastLocation.x !== x || - this._lastLocation.y !== y || (isMoving && this._lastLocation.rotation !== primaryDirection) || this._lastLocation.moving !== isMoving ) { @@ -278,6 +281,14 @@ export default class TownGameScene extends Phaser.Scene { }); this.coveyTownController.emitMovement(this._lastLocation); } + + //Update the location for the labels of all of the other players + for (const player of this._players) { + if (player.gameObjects?.label && player.gameObjects?.sprite.body) { + player.gameObjects.label.setX(player.gameObjects.sprite.body.x); + player.gameObjects.label.setY(player.gameObjects.sprite.body.y - 20); + } + } } } @@ -326,6 +337,7 @@ export default class TownGameScene extends Phaser.Scene { return ret; }); + this._collidingLayers = []; // Parameters: layer name (or index) from Tiled, tileset, x, y const belowLayer = this.map.createLayer('Below Player', tileset, 0, 0); assert(belowLayer); @@ -423,10 +435,11 @@ export default class TownGameScene extends Phaser.Scene { this.moveOurPlayerTo({ rotation: 'front', moving: false, x: spawnPoint.x, y: spawnPoint.y }); // Watch the player and worldLayer for collisions, for the duration of the scene: - this.physics.add.collider(sprite, worldLayer); - this.physics.add.collider(sprite, wallsLayer); - this.physics.add.collider(sprite, aboveLayer); - this.physics.add.collider(sprite, onTheWallsLayer); + this._collidingLayers.push(worldLayer); + this._collidingLayers.push(wallsLayer); + this._collidingLayers.push(aboveLayer); + this._collidingLayers.push(onTheWallsLayer); + this._collidingLayers.forEach(layer => this.physics.add.collider(sprite, layer)); // Create the player's walking animations from the texture atlas. These are stored in the global // animation manager so any sprite can access them. @@ -524,6 +537,7 @@ export default class TownGameScene extends Phaser.Scene { label, locationManagedByGameScene: false, }; + this._collidingLayers.forEach(layer => this.physics.add.collider(sprite, layer)); } } diff --git a/frontend/src/components/Town/TownMap.tsx b/frontend/src/components/Town/TownMap.tsx index 91eb1f6c0..b04dc00e0 100644 --- a/frontend/src/components/Town/TownMap.tsx +++ b/frontend/src/components/Town/TownMap.tsx @@ -5,6 +5,7 @@ import useTownController from '../../hooks/useTownController'; import SocialSidebar from '../SocialSidebar/SocialSidebar'; import NewConversationModal from './interactables/NewCoversationModal'; import TownGameScene from './TownGameScene'; +import TicTacToeAreaWrapper from './interactables/TicTacToe/TicTacToeArea'; export default function TownMap(): JSX.Element { const coveyTownController = useTownController(); @@ -48,6 +49,8 @@ export default function TownMap(): JSX.Element { return (
+ +
diff --git a/frontend/src/components/Town/interactables/ConversationArea.ts b/frontend/src/components/Town/interactables/ConversationArea.ts index 610717258..06fcaaeff 100644 --- a/frontend/src/components/Town/interactables/ConversationArea.ts +++ b/frontend/src/components/Town/interactables/ConversationArea.ts @@ -1,8 +1,8 @@ -import ConversationAreaController from '../../../classes/ConversationAreaController'; -import TownController from '../../../classes/TownController'; +import ConversationAreaController, { + ConversationAreaEvents, +} from '../../../classes/interactable/ConversationAreaController'; import { BoundingBox } from '../../../types/CoveyTownSocket'; import Interactable, { KnownInteractableTypes } from '../Interactable'; -import TownGameScene from '../TownGameScene'; export default class ConversationArea extends Interactable { private _topicTextOrUndefined?: Phaser.GameObjects.Text; @@ -11,15 +11,7 @@ export default class ConversationArea extends Interactable { private _conversationArea?: ConversationAreaController; - private _townController: TownController; - - constructor(scene: TownGameScene) { - super(scene); - this._townController = scene.coveyTownController; - this.setTintFill(); - this.setAlpha(0.3); - this._townController.addListener('conversationAreasChanged', this._updateConversationAreas); - } + private _changeListener?: ConversationAreaEvents['topicChange']; private get _topicText() { const ret = this._topicTextOrUndefined; @@ -33,10 +25,16 @@ export default class ConversationArea extends Interactable { return 'conversationArea'; } - removedFromScene(): void {} + removedFromScene(): void { + if (this._changeListener) { + this._conversationArea?.removeListener('topicChange', this._changeListener); + } + } addedToScene(): void { super.addedToScene(); + this.setTintFill(); + this.setAlpha(0.3); this.scene.add.text( this.x - this.displayWidth / 2, this.y - this.displayHeight / 2, @@ -49,32 +47,22 @@ export default class ConversationArea extends Interactable { '(No Topic)', { color: '#000000' }, ); - this._updateConversationAreas(this._townController.conversationAreas); + this._conversationArea = this.townController.getConversationAreaController(this); + this._updateLabelText(this._conversationArea.topic); + this._changeListener = newTopic => this._updateLabelText(newTopic); + this._conversationArea.addListener('topicChange', this._changeListener); } - private _updateConversationAreas(areas: ConversationAreaController[]) { - const area = areas.find(eachAreaInController => eachAreaInController.id === this.name); - if (area !== this._conversationArea) { - if (area === undefined) { - this._conversationArea = undefined; - this._topicText.text = '(No topic)'; - } else { - this._conversationArea = area; - if (this.isOverlapping) { - this._scene.moveOurPlayerTo({ interactableID: this.name }); - } - const updateListener = (newTopic: string | undefined) => { - if (newTopic) { - if (this._infoTextBox && this._infoTextBox.visible) { - this._infoTextBox.setVisible(false); - } - this._topicText.text = newTopic; - } else { - this._topicText.text = '(No topic)'; - } - }; - updateListener(area.topic); - area.addListener('topicChange', updateListener); + private _updateLabelText(newTopic: string | undefined) { + if (newTopic === undefined) { + this._topicText.text = '(No topic)'; + } else { + if (this.isOverlapping) { + this._scene.moveOurPlayerTo({ interactableID: this.name }); + } + this._topicText.text = newTopic; + if (this._infoTextBox && this._infoTextBox.visible) { + this._infoTextBox.setVisible(false); } } } @@ -101,7 +89,7 @@ export default class ConversationArea extends Interactable { } overlap(): void { - if (this._conversationArea === undefined) { + if (this._conversationArea?.topic === undefined) { this._showInfoBox(); } } diff --git a/frontend/src/components/Town/interactables/GameArea.ts b/frontend/src/components/Town/interactables/GameArea.ts new file mode 100644 index 000000000..5003c3862 --- /dev/null +++ b/frontend/src/components/Town/interactables/GameArea.ts @@ -0,0 +1,33 @@ +import Interactable, { KnownInteractableTypes } from '../Interactable'; + +export default class GameArea extends Interactable { + private _isInteracting = false; + + addedToScene() { + super.addedToScene(); + this.setTintFill(); + this.setAlpha(0.3); + this.setDepth(-1); + this.scene.add.text( + this.x - this.displayWidth / 2, + this.y + this.displayHeight / 2, + this.name, + { color: '#FFFFFF', backgroundColor: '#000000' }, + ); + } + + overlapExit(): void { + if (this._isInteracting) { + this.townController.interactableEmitter.emit('endInteraction', this); + this._isInteracting = false; + } + } + + interact(): void { + this._isInteracting = true; + } + + getType(): KnownInteractableTypes { + return 'gameArea'; + } +} diff --git a/frontend/src/components/Town/interactables/Leaderboard.test.tsx b/frontend/src/components/Town/interactables/Leaderboard.test.tsx new file mode 100644 index 000000000..318983986 --- /dev/null +++ b/frontend/src/components/Town/interactables/Leaderboard.test.tsx @@ -0,0 +1,78 @@ +import { render, screen, within } from '@testing-library/react'; +import { nanoid } from 'nanoid'; +import React from 'react'; +import { GameResult } from '../../../types/CoveyTownSocket'; +import Leaderboard from './Leaderboard'; + +describe('[T4] Leaderboard', () => { + // Spy on console.error and intercept react key warnings to fail test + let consoleErrorSpy: jest.SpyInstance; + beforeAll(() => { + // Spy on console.error and intercept react key warnings to fail test + consoleErrorSpy = jest.spyOn(global.console, 'error'); + consoleErrorSpy.mockImplementation((message?, ...optionalParams) => { + const stringMessage = message as string; + if (stringMessage.includes && stringMessage.includes('children with the same key,')) { + throw new Error(stringMessage.replace('%s', optionalParams[0])); + } else if (stringMessage.includes && stringMessage.includes('warning-keys')) { + throw new Error(stringMessage.replace('%s', optionalParams[0])); + } + // eslint-disable-next-line no-console -- we are wrapping the console with a spy to find react warnings + console.warn(message, ...optionalParams); + }); + }); + afterAll(() => { + consoleErrorSpy.mockRestore(); + }); + + const winner = nanoid(); //two wins, one tie + const middle = nanoid(); //one win, one tie, two loss + const loser = nanoid(); //one loss + const results: GameResult[] = [ + { gameID: nanoid(), scores: { [loser]: 0, [middle]: 1 } }, + { gameID: nanoid(), scores: { [winner]: 1, [middle]: 0 } }, + { gameID: nanoid(), scores: { [winner]: 1, [middle]: 1 } }, + { gameID: nanoid(), scores: { [winner]: 1, [middle]: 0 } }, + ]; + function checkRow( + row: HTMLElement, + player: string, + wins?: number, + losses?: number, + ties?: number, + ) { + const columns = within(row).getAllByRole('gridcell'); + expect(columns).toHaveLength(4); + expect(columns[0]).toHaveTextContent(player); + if (wins) expect(columns[1]).toHaveTextContent(wins.toString()); + if (losses) expect(columns[2]).toHaveTextContent(losses.toString()); + if (ties) expect(columns[3]).toHaveTextContent(ties.toString()); + } + beforeEach(() => { + render(); + }); + it('should render a table with the correct headers', () => { + const headers = screen.getAllByRole('columnheader'); + expect(headers).toHaveLength(4); + expect(headers[0]).toHaveTextContent('Player'); + expect(headers[1]).toHaveTextContent('Wins'); + expect(headers[2]).toHaveTextContent('Losses'); + expect(headers[3]).toHaveTextContent('Ties'); + }); + it('should render a row for each player', () => { + const rows = screen.getAllByRole('row'); + expect(rows).toHaveLength(4); + }); + it('should render the players in order of wins', () => { + const rows = screen.getAllByRole('row'); + checkRow(rows[1], winner); + checkRow(rows[2], middle); + checkRow(rows[3], loser); + }); + it('should calculate the cumulative number of wins, losses, and ties for each player', () => { + const rows = screen.getAllByRole('row'); + checkRow(rows[1], winner, 2, 0, 1); + checkRow(rows[2], middle, 1, 2, 1); + checkRow(rows[3], loser, 0, 1, 0); + }); +}); diff --git a/frontend/src/components/Town/interactables/Leaderboard.tsx b/frontend/src/components/Town/interactables/Leaderboard.tsx new file mode 100644 index 000000000..346065047 --- /dev/null +++ b/frontend/src/components/Town/interactables/Leaderboard.tsx @@ -0,0 +1,96 @@ +import { Table, Tbody, Td, Thead, Tr } from '@chakra-ui/react'; +import React from 'react'; +import { GameResult } from '../../../types/CoveyTownSocket'; + +/** + * A component that renders a list of GameResult's as a leaderboard, formatted as a table with the following columns: + * - Player: the name of the player + * - Wins: the number of games the player has won + * - Losses: the number of games the player has lost + * - Ties: the number of games the player has tied + * Each column has a header (a table header `th` element) with the name of the column. + * + * + * The table is sorted by the number of wins, with the player with the most wins at the top. + * + * @returns + */ +export default function Leaderboard({ results }: { results: GameResult[] }): JSX.Element { + const winsLossesTiesByPlayer: Record< + string, + { player: string; wins: number; losses: number; ties: number } + > = {}; + results.forEach(result => { + const players = Object.keys(result.scores); + const p1 = players[0]; + const p2 = players[1]; + const winner = + result.scores[p1] > result.scores[p2] + ? p1 + : result.scores[p2] > result.scores[p1] + ? p2 + : undefined; + const loser = + result.scores[p1] < result.scores[p2] + ? p1 + : result.scores[p2] < result.scores[p1] + ? p2 + : undefined; + if (winner) { + winsLossesTiesByPlayer[winner] = { + player: winner, + wins: (winsLossesTiesByPlayer[winner]?.wins || 0) + 1, + losses: winsLossesTiesByPlayer[winner]?.losses || 0, + ties: winsLossesTiesByPlayer[winner]?.ties || 0, + }; + } + if (loser) { + winsLossesTiesByPlayer[loser] = { + player: loser, + wins: winsLossesTiesByPlayer[loser]?.wins || 0, + losses: (winsLossesTiesByPlayer[loser]?.losses || 0) + 1, + ties: winsLossesTiesByPlayer[loser]?.ties || 0, + }; + } + if (!winner && !loser) { + winsLossesTiesByPlayer[p1] = { + player: p1, + wins: winsLossesTiesByPlayer[p1]?.wins || 0, + losses: winsLossesTiesByPlayer[p1]?.losses || 0, + ties: (winsLossesTiesByPlayer[p1]?.ties || 0) + 1, + }; + winsLossesTiesByPlayer[p2] = { + player: p2, + wins: winsLossesTiesByPlayer[p2]?.wins || 0, + losses: winsLossesTiesByPlayer[p2]?.losses || 0, + ties: (winsLossesTiesByPlayer[p2]?.ties || 0) + 1, + }; + } + }); + const rows = Object.keys(winsLossesTiesByPlayer).map(player => winsLossesTiesByPlayer[player]); + rows.sort((a, b) => b.wins - a.wins); + return ( + + + + + + + + + + + {rows.map(record => { + return ( + + + + + + + ); + })} + +
PlayerWinsLossesTies
{record.player}{record.wins}{record.losses}{record.ties}
+ ); +} diff --git a/frontend/src/components/Town/interactables/NewCoversationModal.tsx b/frontend/src/components/Town/interactables/NewCoversationModal.tsx index c3856a295..1ed85c1e7 100644 --- a/frontend/src/components/Town/interactables/NewCoversationModal.tsx +++ b/frontend/src/components/Town/interactables/NewCoversationModal.tsx @@ -14,7 +14,7 @@ import { } from '@chakra-ui/react'; import React, { useCallback, useEffect, useState } from 'react'; import { useInteractable } from '../../../classes/TownController'; -import { ConversationArea } from '../../../generated/client'; +import { Omit_ConversationArea_type_ } from '../../../generated/client'; import useTownController from '../../../hooks/useTownController'; export default function NewConversationModal(): JSX.Element { @@ -42,10 +42,10 @@ export default function NewConversationModal(): JSX.Element { const createConversation = useCallback(async () => { if (topic && newConversation) { - const conversationToCreate: ConversationArea = { + const conversationToCreate: Omit_ConversationArea_type_ = { topic, id: newConversation.name, - occupantsByID: [], + occupants: [], }; try { await coveyTownController.createConversationArea(conversationToCreate); diff --git a/frontend/src/components/Town/interactables/SelectVideoModal.tsx b/frontend/src/components/Town/interactables/SelectVideoModal.tsx index 3a653f85a..6c62947a7 100644 --- a/frontend/src/components/Town/interactables/SelectVideoModal.tsx +++ b/frontend/src/components/Town/interactables/SelectVideoModal.tsx @@ -13,9 +13,9 @@ import { useToast, } from '@chakra-ui/react'; import React, { useCallback, useEffect, useState } from 'react'; -import { useViewingAreaController } from '../../../classes/TownController'; +import ViewingAreaController from '../../../classes/interactable/ViewingAreaController'; +import { useInteractableAreaController } from '../../../classes/TownController'; import useTownController from '../../../hooks/useTownController'; -import { ViewingArea as ViewingAreaModel } from '../../../types/CoveyTownSocket'; import ViewingArea from './ViewingArea'; export default function SelectVideoModal({ @@ -28,7 +28,9 @@ export default function SelectVideoModal({ viewingArea: ViewingArea; }): JSX.Element { const coveyTownController = useTownController(); - const viewingAreaController = useViewingAreaController(viewingArea?.name); + const viewingAreaController = useInteractableAreaController( + viewingArea?.name, + ); const [video, setVideo] = useState(viewingArea?.defaultVideoURL || ''); @@ -49,11 +51,12 @@ export default function SelectVideoModal({ const createViewingArea = useCallback(async () => { if (video && viewingAreaController) { - const request: ViewingAreaModel = { + const request = { id: viewingAreaController.id, video, isPlaying: true, elapsedTimeSec: 0, + occupants: [], }; try { await coveyTownController.createViewingArea(request); diff --git a/frontend/src/components/Town/interactables/TicTacToe/TicTacToeArea.test.tsx b/frontend/src/components/Town/interactables/TicTacToe/TicTacToeArea.test.tsx new file mode 100644 index 000000000..35f91bfed --- /dev/null +++ b/frontend/src/components/Town/interactables/TicTacToe/TicTacToeArea.test.tsx @@ -0,0 +1,695 @@ +import { ChakraProvider } from '@chakra-ui/react'; +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { mock, mockReset } from 'jest-mock-extended'; +import React from 'react'; +import { nanoid } from 'nanoid'; +import { act } from 'react-dom/test-utils'; +import TicTacToeAreaController, { + TicTacToeCell, +} from '../../../../classes/interactable/TicTacToeAreaController'; +import PlayerController from '../../../../classes/PlayerController'; +import TownController, * as TownControllerHooks from '../../../../classes/TownController'; +import TownControllerContext from '../../../../contexts/TownControllerContext'; +import { + GameArea, + GameResult, + GameStatus, + PlayerLocation, + TicTacToeGameState, +} from '../../../../types/CoveyTownSocket'; +import PhaserGameArea from '../GameArea'; +import * as Leaderboard from '../Leaderboard'; +import TicTacToeAreaWrapper from './TicTacToeArea'; +import * as TicTacToeBoard from './TicTacToeBoard'; + +const mockToast = jest.fn(); +jest.mock('@chakra-ui/react', () => { + const ui = jest.requireActual('@chakra-ui/react'); + const mockUseToast = () => mockToast; + return { + ...ui, + useToast: mockUseToast, + }; +}); +const mockGameArea = mock(); +mockGameArea.getData.mockReturnValue('TicTacToe'); +jest.spyOn(TownControllerHooks, 'useInteractable').mockReturnValue(mockGameArea); +const useInteractableAreaControllerSpy = jest.spyOn( + TownControllerHooks, + 'useInteractableAreaController', +); + +const leaderboardComponentSpy = jest.spyOn(Leaderboard, 'default'); +leaderboardComponentSpy.mockReturnValue(
); + +const boardComponentSpy = jest.spyOn(TicTacToeBoard, 'default'); +boardComponentSpy.mockReturnValue(
); + +const randomLocation = (): PlayerLocation => ({ + moving: Math.random() < 0.5, + rotation: 'front', + x: Math.random() * 1000, + y: Math.random() * 1000, +}); + +class MockTicTacToeAreaController extends TicTacToeAreaController { + makeMove = jest.fn(); + + joinGame = jest.fn(); + + mockBoard: TicTacToeCell[][] = [ + [undefined, undefined, undefined], + [undefined, undefined, undefined], + [undefined, undefined, undefined], + ]; + + mockIsPlayer = false; + + mockIsOurTurn = false; + + mockObservers: PlayerController[] = []; + + mockMoveCount = 0; + + mockWinner: PlayerController | undefined = undefined; + + mockWhoseTurn: PlayerController | undefined = undefined; + + mockStatus: GameStatus = 'WAITING_TO_START'; + + mockX: PlayerController | undefined = undefined; + + mockO: PlayerController | undefined = undefined; + + mockCurrentGame: GameArea | undefined = undefined; + + mockGamePiece: 'X' | 'O' = 'X'; + + mockIsActive = false; + + mockHistory: GameResult[] = []; + + public constructor() { + super(nanoid(), mock>(), mock()); + } + + get board(): TicTacToeCell[][] { + throw new Error('Method should not be called within this component.'); + } + + get history(): GameResult[] { + return this.mockHistory; + } + + get isOurTurn() { + return this.mockIsOurTurn; + } + + get x(): PlayerController | undefined { + return this.mockX; + } + + get o(): PlayerController | undefined { + return this.mockO; + } + + get observers(): PlayerController[] { + return this.mockObservers; + } + + get moveCount(): number { + return this.mockMoveCount; + } + + get winner(): PlayerController | undefined { + return this.mockWinner; + } + + get whoseTurn(): PlayerController | undefined { + return this.mockWhoseTurn; + } + + get status(): GameStatus { + return this.mockStatus; + } + + get isPlayer() { + return this.mockIsPlayer; + } + + get gamePiece(): 'X' | 'O' { + return this.mockGamePiece; + } + + public isActive(): boolean { + return this.mockIsActive; + } + + public mockReset() { + this.mockBoard = [ + ['X', 'O', undefined], + [undefined, 'X', undefined], + [undefined, undefined, 'O'], + ]; + this.makeMove.mockReset(); + } +} +describe('[T2] TicTacToeArea', () => { + // Spy on console.error and intercept react key warnings to fail test + let consoleErrorSpy: jest.SpyInstance; + beforeAll(() => { + // Spy on console.error and intercept react key warnings to fail test + consoleErrorSpy = jest.spyOn(global.console, 'error'); + consoleErrorSpy.mockImplementation((message?, ...optionalParams) => { + const stringMessage = message as string; + if (stringMessage.includes && stringMessage.includes('children with the same key,')) { + throw new Error(stringMessage.replace('%s', optionalParams[0])); + } else if (stringMessage.includes && stringMessage.includes('warning-keys')) { + throw new Error(stringMessage.replace('%s', optionalParams[0])); + } + // eslint-disable-next-line no-console -- we are wrapping the console with a spy to find react warnings + console.warn(message, ...optionalParams); + }); + }); + afterAll(() => { + consoleErrorSpy.mockRestore(); + }); + + let ourPlayer: PlayerController; + const townController = mock(); + Object.defineProperty(townController, 'ourPlayer', { get: () => ourPlayer }); + let gameAreaController = new MockTicTacToeAreaController(); + let joinGameResolve: () => void; + let joinGameReject: (err: Error) => void; + + function renderTicTacToeArea() { + return render( + + + + + , + ); + } + beforeEach(() => { + ourPlayer = new PlayerController('player x', 'player x', randomLocation()); + mockGameArea.name = nanoid(); + mockReset(townController); + gameAreaController.mockReset(); + useInteractableAreaControllerSpy.mockReturnValue(gameAreaController); + leaderboardComponentSpy.mockClear(); + mockToast.mockClear(); + gameAreaController.joinGame.mockReset(); + gameAreaController.makeMove.mockReset(); + + gameAreaController.joinGame.mockImplementation( + () => + new Promise((resolve, reject) => { + joinGameResolve = resolve; + joinGameReject = reject; + }), + ); + }); + describe('[T2.1] Game update listeners', () => { + it('Registers exactly two listeners when mounted: one for gameUpdated and one for gameEnd', () => { + const addListenerSpy = jest.spyOn(gameAreaController, 'addListener'); + addListenerSpy.mockClear(); + + renderTicTacToeArea(); + expect(addListenerSpy).toBeCalledTimes(2); + expect(addListenerSpy).toHaveBeenCalledWith('gameUpdated', expect.any(Function)); + expect(addListenerSpy).toHaveBeenCalledWith('gameEnd', expect.any(Function)); + }); + it('Does not register listeners on every render', () => { + const removeListenerSpy = jest.spyOn(gameAreaController, 'removeListener'); + const addListenerSpy = jest.spyOn(gameAreaController, 'addListener'); + addListenerSpy.mockClear(); + removeListenerSpy.mockClear(); + const renderData = renderTicTacToeArea(); + expect(addListenerSpy).toBeCalledTimes(2); + addListenerSpy.mockClear(); + + renderData.rerender( + + + + + , + ); + + expect(addListenerSpy).not.toBeCalled(); + expect(removeListenerSpy).not.toBeCalled(); + }); + it('Removes the listeners when the component is unmounted', () => { + const removeListenerSpy = jest.spyOn(gameAreaController, 'removeListener'); + const addListenerSpy = jest.spyOn(gameAreaController, 'addListener'); + addListenerSpy.mockClear(); + removeListenerSpy.mockClear(); + const renderData = renderTicTacToeArea(); + expect(addListenerSpy).toBeCalledTimes(2); + const addedListeners = addListenerSpy.mock.calls; + const addedGameUpdateListener = addedListeners.find(call => call[0] === 'gameUpdated'); + const addedGameEndedListener = addedListeners.find(call => call[0] === 'gameEnd'); + expect(addedGameEndedListener).toBeDefined(); + expect(addedGameUpdateListener).toBeDefined(); + renderData.unmount(); + expect(removeListenerSpy).toBeCalledTimes(2); + const removedListeners = removeListenerSpy.mock.calls; + const removedGameUpdateListener = removedListeners.find(call => call[0] === 'gameUpdated'); + const removedGameEndedListener = removedListeners.find(call => call[0] === 'gameEnd'); + expect(removedGameUpdateListener).toEqual(addedGameUpdateListener); + expect(removedGameEndedListener).toEqual(addedGameEndedListener); + }); + it('Creates new listeners if the gameAreaController changes', () => { + const removeListenerSpy = jest.spyOn(gameAreaController, 'removeListener'); + const addListenerSpy = jest.spyOn(gameAreaController, 'addListener'); + addListenerSpy.mockClear(); + removeListenerSpy.mockClear(); + const renderData = renderTicTacToeArea(); + expect(addListenerSpy).toBeCalledTimes(2); + + gameAreaController = new MockTicTacToeAreaController(); + const removeListenerSpy2 = jest.spyOn(gameAreaController, 'removeListener'); + const addListenerSpy2 = jest.spyOn(gameAreaController, 'addListener'); + + useInteractableAreaControllerSpy.mockReturnValue(gameAreaController); + renderData.rerender( + + + + + , + ); + expect(removeListenerSpy).toBeCalledTimes(2); + + expect(addListenerSpy2).toBeCalledTimes(2); + expect(removeListenerSpy2).not.toBeCalled(); + }); + }); + describe('[T2.2] Rendering the leaderboard', () => { + it('Renders the leaderboard with the history when the component is mounted', () => { + gameAreaController.mockHistory = [ + { + gameID: nanoid(), + scores: { + [nanoid()]: 1, + [nanoid()]: 0, + }, + }, + ]; + renderTicTacToeArea(); + expect(leaderboardComponentSpy).toHaveBeenCalledWith( + { + results: gameAreaController.mockHistory, + }, + {}, + ); + }); + it('Renders the leaderboard with the history when the game is updated', () => { + gameAreaController.mockHistory = [ + { + gameID: nanoid(), + scores: { + [nanoid()]: 1, + [nanoid()]: 0, + }, + }, + ]; + renderTicTacToeArea(); + expect(leaderboardComponentSpy).toHaveBeenCalledWith( + { + results: gameAreaController.mockHistory, + }, + {}, + ); + + gameAreaController.mockHistory = [ + { + gameID: nanoid(), + scores: { + [nanoid()]: 1, + [nanoid()]: 1, + }, + }, + ]; + act(() => { + gameAreaController.emit('gameUpdated'); + }); + expect(leaderboardComponentSpy).toHaveBeenCalledWith( + { + results: gameAreaController.mockHistory, + }, + {}, + ); + }); + }); + describe('[T2.3] Join game button', () => { + it('Is not shown when the player is in a not-yet-started game', () => { + gameAreaController.mockStatus = 'WAITING_TO_START'; + gameAreaController.mockX = ourPlayer; + gameAreaController.mockIsPlayer = true; + renderTicTacToeArea(); + expect(screen.queryByText('Join New Game')).not.toBeInTheDocument(); + }); + it('Is not shown if the game is in progress', () => { + gameAreaController.mockStatus = 'IN_PROGRESS'; + gameAreaController.mockX = new PlayerController('player X', 'player X', randomLocation()); + gameAreaController.mockO = new PlayerController('player O', 'player O', randomLocation()); + gameAreaController.mockIsPlayer = false; + renderTicTacToeArea(); + expect(screen.queryByText('Join New Game')).not.toBeInTheDocument(); + }); + it('Is enabled when the player is not in a game and the game is not in progress', () => { + gameAreaController.mockStatus = 'WAITING_TO_START'; + gameAreaController.mockX = undefined; + gameAreaController.mockO = new PlayerController('player O', 'player O', randomLocation()); + gameAreaController.mockIsPlayer = false; + renderTicTacToeArea(); + expect(screen.queryByText('Join New Game')).toBeInTheDocument(); + }); + describe('When clicked', () => { + it('Calls joinGame on the gameAreaController', () => { + gameAreaController.mockStatus = 'WAITING_TO_START'; + gameAreaController.mockIsPlayer = false; + renderTicTacToeArea(); + const button = screen.getByText('Join New Game'); + fireEvent.click(button); + expect(gameAreaController.joinGame).toBeCalled(); + }); + it('Displays a toast with the error message if there is an error joining the game', async () => { + gameAreaController.mockStatus = 'WAITING_TO_START'; + gameAreaController.mockIsPlayer = false; + const errorMessage = nanoid(); + renderTicTacToeArea(); + const button = screen.getByText('Join New Game'); + fireEvent.click(button); + expect(gameAreaController.joinGame).toBeCalled(); + act(() => { + joinGameReject(new Error(errorMessage)); + }); + await waitFor(() => { + expect(mockToast).toBeCalledWith( + expect.objectContaining({ + description: `Error: ${errorMessage}`, + status: 'error', + }), + ); + }); + }); + + it('Is disabled and set to loading when the player is joining a game', async () => { + gameAreaController.mockStatus = 'WAITING_TO_START'; + gameAreaController.mockIsPlayer = false; + renderTicTacToeArea(); + const button = screen.getByText('Join New Game'); + expect(button).toBeEnabled(); + expect(within(button).queryByText('Loading...')).not.toBeInTheDocument(); //Check that the loading text is not displayed + fireEvent.click(button); + expect(gameAreaController.joinGame).toBeCalled(); + expect(button).toBeDisabled(); + expect(within(button).queryByText('Loading...')).toBeInTheDocument(); //Check that the loading text is displayed + act(() => { + joinGameResolve(); + }); + await waitFor(() => expect(button).toBeEnabled()); + expect(within(button).queryByText('Loading...')).not.toBeInTheDocument(); //Check that the loading text is not displayed + }); + }); + it('Adds the display of the button when a game becomes possible to join', () => { + gameAreaController.mockStatus = 'IN_PROGRESS'; + gameAreaController.mockIsPlayer = false; + gameAreaController.mockX = new PlayerController('player X', 'player X', randomLocation()); + gameAreaController.mockO = new PlayerController('player O', 'player O', randomLocation()); + renderTicTacToeArea(); + expect(screen.queryByText('Join New Game')).not.toBeInTheDocument(); + act(() => { + gameAreaController.mockStatus = 'OVER'; + gameAreaController.emit('gameUpdated'); + }); + expect(screen.queryByText('Join New Game')).toBeInTheDocument(); + }); + it('Removes the display of the button when a game becomes no longer possible to join', () => { + gameAreaController.mockStatus = 'WAITING_TO_START'; + gameAreaController.mockIsPlayer = false; + gameAreaController.mockX = undefined; + gameAreaController.mockO = new PlayerController('player O', 'player O', randomLocation()); + renderTicTacToeArea(); + expect(screen.queryByText('Join New Game')).toBeInTheDocument(); + act(() => { + gameAreaController.mockStatus = 'IN_PROGRESS'; + gameAreaController.mockX = new PlayerController('player X', 'player X', randomLocation()); + gameAreaController.emit('gameUpdated'); + }); + expect(screen.queryByText('Join New Game')).not.toBeInTheDocument(); + }); + }); + describe('[T2.4] Rendering the current observers', () => { + beforeEach(() => { + gameAreaController.mockObservers = [ + new PlayerController('player 1', 'player 1', randomLocation()), + new PlayerController('player 2', 'player 2', randomLocation()), + new PlayerController('player 3', 'player 3', randomLocation()), + ]; + gameAreaController.mockStatus = 'IN_PROGRESS'; + gameAreaController.mockIsPlayer = false; + gameAreaController.mockX = new PlayerController('player X', 'player X', randomLocation()); + gameAreaController.mockO = new PlayerController('player O', 'player O', randomLocation()); + }); + it('Displays the correct observers when the component is mounted', () => { + renderTicTacToeArea(); + const observerList = screen.getByLabelText('list of observers in the game'); + const observerItems = observerList.querySelectorAll('li'); + expect(observerItems).toHaveLength(gameAreaController.mockObservers.length); + for (let i = 0; i < observerItems.length; i++) { + expect(observerItems[i]).toHaveTextContent(gameAreaController.mockObservers[i].userName); + } + }); + it('Displays the correct observers when the game is updated', () => { + renderTicTacToeArea(); + act(() => { + gameAreaController.mockObservers = [ + new PlayerController('player 1', 'player 1', randomLocation()), + new PlayerController('player 2', 'player 2', randomLocation()), + new PlayerController('player 3', 'player 3', randomLocation()), + new PlayerController('player 4', 'player 4', randomLocation()), + ]; + gameAreaController.emit('gameUpdated'); + }); + const observerList = screen.getByLabelText('list of observers in the game'); + const observerItems = observerList.querySelectorAll('li'); + expect(observerItems).toHaveLength(gameAreaController.mockObservers.length); + for (let i = 0; i < observerItems.length; i++) { + expect(observerItems[i]).toHaveTextContent(gameAreaController.mockObservers[i].userName); + } + }); + }); + describe('[T2.5] Players in the game text', () => { + it('Displays the username of the X player if the X player is in the game', () => { + gameAreaController.mockStatus = 'IN_PROGRESS'; + gameAreaController.mockIsPlayer = false; + gameAreaController.mockX = new PlayerController(nanoid(), nanoid(), randomLocation()); + renderTicTacToeArea(); + const listOfPlayers = screen.getByLabelText('list of players in the game'); + expect( + within(listOfPlayers).getByText(`X: ${gameAreaController.mockX?.userName}`), + ).toBeInTheDocument(); + }); + it('Displays the username of the O player if the O player is in the game', () => { + gameAreaController.mockStatus = 'IN_PROGRESS'; + gameAreaController.mockIsPlayer = false; + gameAreaController.mockO = new PlayerController(nanoid(), nanoid(), randomLocation()); + renderTicTacToeArea(); + const listOfPlayers = screen.getByLabelText('list of players in the game'); + expect( + within(listOfPlayers).getByText(`O: ${gameAreaController.mockO?.userName}`), + ).toBeInTheDocument(); + }); + it('Displays "X: (No player yet!)" if the X player is not in the game', () => { + gameAreaController.mockStatus = 'IN_PROGRESS'; + gameAreaController.mockIsPlayer = false; + gameAreaController.mockX = undefined; + renderTicTacToeArea(); + const listOfPlayers = screen.getByLabelText('list of players in the game'); + expect(within(listOfPlayers).getByText(`X: (No player yet!)`)).toBeInTheDocument(); + }); + it('Displays "O: (No player yet!)" if the O player is not in the game', () => { + gameAreaController.mockStatus = 'IN_PROGRESS'; + gameAreaController.mockIsPlayer = false; + gameAreaController.mockO = undefined; + renderTicTacToeArea(); + const listOfPlayers = screen.getByLabelText('list of players in the game'); + expect(within(listOfPlayers).getByText(`O: (No player yet!)`)).toBeInTheDocument(); + }); + it('Updates the X player when the game is updated', () => { + gameAreaController.mockStatus = 'IN_PROGRESS'; + gameAreaController.mockIsPlayer = false; + renderTicTacToeArea(); + const listOfPlayers = screen.getByLabelText('list of players in the game'); + expect(within(listOfPlayers).getByText(`X: (No player yet!)`)).toBeInTheDocument(); + act(() => { + gameAreaController.mockX = new PlayerController(nanoid(), nanoid(), randomLocation()); + gameAreaController.emit('gameUpdated'); + }); + expect( + within(listOfPlayers).getByText(`X: ${gameAreaController.mockX?.userName}`), + ).toBeInTheDocument(); + }); + it('Updates the O player when the game is updated', () => { + gameAreaController.mockStatus = 'IN_PROGRESS'; + gameAreaController.mockIsPlayer = false; + renderTicTacToeArea(); + const listOfPlayers = screen.getByLabelText('list of players in the game'); + expect(within(listOfPlayers).getByText(`O: (No player yet!)`)).toBeInTheDocument(); + act(() => { + gameAreaController.mockO = new PlayerController(nanoid(), nanoid(), randomLocation()); + gameAreaController.emit('gameUpdated'); + }); + expect( + within(listOfPlayers).getByText(`O: ${gameAreaController.mockO?.userName}`), + ).toBeInTheDocument(); + }); + }); + describe('[T2.6] Game status text', () => { + it('Displays the correct text when the game is waiting to start', () => { + gameAreaController.mockStatus = 'WAITING_TO_START'; + renderTicTacToeArea(); + expect(screen.getByText('Game not yet started', { exact: false })).toBeInTheDocument(); + }); + it('Displays the correct text when the game is in progress', () => { + gameAreaController.mockStatus = 'IN_PROGRESS'; + renderTicTacToeArea(); + expect(screen.getByText('Game in progress', { exact: false })).toBeInTheDocument(); + }); + it('Displays the correct text when the game is over', () => { + gameAreaController.mockStatus = 'OVER'; + renderTicTacToeArea(); + expect(screen.getByText('Game over', { exact: false })).toBeInTheDocument(); + }); + describe('When a game is in progress', () => { + beforeEach(() => { + gameAreaController.mockStatus = 'IN_PROGRESS'; + gameAreaController.mockMoveCount = 2; + gameAreaController.mockX = ourPlayer; + gameAreaController.mockO = new PlayerController('player O', 'player O', randomLocation()); + gameAreaController.mockWhoseTurn = gameAreaController.mockX; + gameAreaController.mockIsOurTurn = true; + }); + + it('Displays a message "Game in progress, {numMoves} moves in" and indicates whose turn it is when it is our turn', () => { + renderTicTacToeArea(); + expect( + screen.getByText(`Game in progress, 2 moves in, currently your turn`, { exact: false }), + ).toBeInTheDocument(); + }); + + it('Displays a message "Game in progress, {numMoves} moves in" and indicates whose turn it is when it is the other player\'s turn', () => { + gameAreaController.mockMoveCount = 1; + gameAreaController.mockWhoseTurn = gameAreaController.mockO; + gameAreaController.mockIsOurTurn = false; + renderTicTacToeArea(); + expect( + screen.getByText( + `Game in progress, 1 moves in, currently ${gameAreaController.o?.userName}'s turn`, + { exact: false }, + ), + ).toBeInTheDocument(); + }); + + it('Updates the move count when the game is updated', () => { + renderTicTacToeArea(); + expect( + screen.getByText(`Game in progress, 2 moves in`, { exact: false }), + ).toBeInTheDocument(); + act(() => { + gameAreaController.mockMoveCount = 3; + gameAreaController.mockWhoseTurn = gameAreaController.mockO; + gameAreaController.mockIsOurTurn = false; + gameAreaController.emit('gameUpdated'); + }); + expect( + screen.getByText(`Game in progress, 3 moves in`, { exact: false }), + ).toBeInTheDocument(); + }); + it('Updates the whose turn it is when the game is updated', () => { + renderTicTacToeArea(); + expect(screen.getByText(`, currently your turn`, { exact: false })).toBeInTheDocument(); + act(() => { + gameAreaController.mockMoveCount = 3; + gameAreaController.mockWhoseTurn = gameAreaController.mockO; + gameAreaController.mockIsOurTurn = false; + gameAreaController.emit('gameUpdated'); + }); + expect( + screen.getByText(`, currently ${gameAreaController.mockO?.userName}'s turn`, { + exact: false, + }), + ).toBeInTheDocument(); + }); + }); + it('Updates the game status text when the game is updated', () => { + gameAreaController.mockStatus = 'WAITING_TO_START'; + renderTicTacToeArea(); + expect(screen.getByText('Game not yet started', { exact: false })).toBeInTheDocument(); + act(() => { + gameAreaController.mockStatus = 'IN_PROGRESS'; + gameAreaController.emit('gameUpdated'); + }); + expect(screen.getByText('Game in progress', { exact: false })).toBeInTheDocument(); + act(() => { + gameAreaController.mockStatus = 'OVER'; + gameAreaController.emit('gameUpdated'); + }); + expect(screen.getByText('Game over', { exact: false })).toBeInTheDocument(); + }); + describe('When the game ends', () => { + it('Displays a toast with the winner', () => { + gameAreaController.mockStatus = 'IN_PROGRESS'; + gameAreaController.mockIsPlayer = false; + gameAreaController.mockX = ourPlayer; + gameAreaController.mockO = new PlayerController('player O', 'player O', randomLocation()); + gameAreaController.mockWinner = ourPlayer; + renderTicTacToeArea(); + act(() => { + gameAreaController.emit('gameEnd'); + }); + expect(mockToast).toBeCalledWith( + expect.objectContaining({ + description: `You won!`, + }), + ); + }); + it('Displays a toast with the loser', () => { + gameAreaController.mockStatus = 'IN_PROGRESS'; + gameAreaController.mockIsPlayer = false; + gameAreaController.mockX = ourPlayer; + gameAreaController.mockO = new PlayerController('player O', 'player O', randomLocation()); + gameAreaController.mockWinner = gameAreaController.mockO; + renderTicTacToeArea(); + act(() => { + gameAreaController.emit('gameEnd'); + }); + expect(mockToast).toBeCalledWith( + expect.objectContaining({ + description: `You lost :(`, + }), + ); + }); + it('Displays a toast with a tie', () => { + gameAreaController.mockStatus = 'IN_PROGRESS'; + gameAreaController.mockIsPlayer = false; + gameAreaController.mockX = ourPlayer; + gameAreaController.mockO = new PlayerController('player O', 'player O', randomLocation()); + gameAreaController.mockWinner = undefined; + renderTicTacToeArea(); + act(() => { + gameAreaController.emit('gameEnd'); + }); + expect(mockToast).toBeCalledWith( + expect.objectContaining({ + description: `Game ended in a tie`, + }), + ); + }); + }); + }); +}); diff --git a/frontend/src/components/Town/interactables/TicTacToe/TicTacToeArea.tsx b/frontend/src/components/Town/interactables/TicTacToe/TicTacToeArea.tsx new file mode 100644 index 000000000..162a759b0 --- /dev/null +++ b/frontend/src/components/Town/interactables/TicTacToe/TicTacToeArea.tsx @@ -0,0 +1,233 @@ +import { + Accordion, + AccordionButton, + AccordionIcon, + AccordionItem, + AccordionPanel, + Box, + Button, + Container, + Heading, + List, + ListItem, + Modal, + ModalCloseButton, + ModalContent, + ModalHeader, + ModalOverlay, + useToast, +} from '@chakra-ui/react'; +import React, { useCallback, useEffect, useState } from 'react'; +import TicTacToeAreaController from '../../../../classes/interactable/TicTacToeAreaController'; +import PlayerController from '../../../../classes/PlayerController'; +import { useInteractable, useInteractableAreaController } from '../../../../classes/TownController'; +import useTownController from '../../../../hooks/useTownController'; +import { GameResult, GameStatus, InteractableID } from '../../../../types/CoveyTownSocket'; +import GameAreaInteractable from '../GameArea'; +import TicTacToeLeaderboard from '../Leaderboard'; +import TicTacToeBoard from './TicTacToeBoard'; + +/** + * The TicTacToeArea component renders the TicTacToe game area. + * It renders the current state of the area, optionally allowing the player to join the game. + * + * It uses Chakra-UI components (does not use other GUI widgets) + * + * It uses the TicTacToeAreaController to get the current state of the game. + * It listens for the 'gameUpdated' and 'gameEnd' events on the controller, and re-renders accordingly. + * It subscribes to these events when the component mounts, and unsubscribes when the component unmounts. It also unsubscribes when the gameAreaController changes. + * + * It renders the following: + * - A leaderboard (@see Leaderboard.tsx), which is passed the game history as a prop + * - A list of observers' usernames (in a list with the aria-label 'list of observers in the game', one username per-listitem) + * - A list of players' usernames (in a list with the aria-label 'list of players in the game', one item for X and one for O) + * - If there is no player in the game, the username is '(No player yet!)' + * - List the players as (exactly) `X: ${username}` and `O: ${username}` + * - A message indicating the current game status: + * - If the game is in progress, the message is 'Game in progress, {moveCount} moves in, currently {whoseTurn}'s turn'. If it is currently our player's turn, the message is 'Game in progress, {moveCount} moves in, currently your turn' + * - Otherwise the message is 'Game {not yet started | over}.' + * - If the game is in status WAITING_TO_START or OVER, a button to join the game is displayed, with the text 'Join New Game' + * - Clicking the button calls the joinGame method on the gameAreaController + * - Before calling joinGame method, the button is disabled and has the property isLoading set to true, and is re-enabled when the method call completes + * - If the method call fails, a toast is displayed with the error message as the description of the toast (and status 'error') + * - Once the player joins the game, the button dissapears + * - The TicTacToeBoard component, which is passed the current gameAreaController as a prop (@see TicTacToeBoard.tsx) + * + * - When the game ends, a toast is displayed with the result of the game: + * - Tie: description 'Game ended in a tie' + * - Our player won: description 'You won!' + * - Our player lost: description 'You lost :(' + * + */ +function TicTacToeArea({ interactableID }: { interactableID: InteractableID }): JSX.Element { + const gameAreaController = useInteractableAreaController(interactableID); + const townController = useTownController(); + + const [history, setHistory] = useState(gameAreaController.history); + const [gameStatus, setGameStatus] = useState(gameAreaController.status); + const [moveCount, setMoveCount] = useState(gameAreaController.moveCount); + const [observers, setObservers] = useState(gameAreaController.observers); + const [joiningGame, setJoiningGame] = useState(false); + const [x, setX] = useState(gameAreaController.x); + const [o, setO] = useState(gameAreaController.o); + const toast = useToast(); + + useEffect(() => { + const updateGameState = () => { + setHistory(gameAreaController.history); + setGameStatus(gameAreaController.status || 'WAITING_TO_START'); + setMoveCount(gameAreaController.moveCount || 0); + setObservers(gameAreaController.observers); + setX(gameAreaController.x); + setO(gameAreaController.o); + }; + gameAreaController.addListener('gameUpdated', updateGameState); + const onGameEnd = () => { + const winner = gameAreaController.winner; + if (!winner) { + toast({ + title: 'Game over', + description: 'Game ended in a tie', + status: 'info', + }); + } else if (winner === townController.ourPlayer) { + toast({ + title: 'Game over', + description: 'You won!', + status: 'success', + }); + } else { + toast({ + title: 'Game over', + description: `You lost :(`, + status: 'error', + }); + } + }; + gameAreaController.addListener('gameEnd', onGameEnd); + return () => { + gameAreaController.removeListener('gameEnd', onGameEnd); + gameAreaController.removeListener('gameUpdated', updateGameState); + }; + }, [townController, gameAreaController, toast]); + + let gameStatusText = <>; + if (gameStatus === 'IN_PROGRESS') { + gameStatusText = ( + <> + Game in progress, {moveCount} moves in, currently{' '} + {gameAreaController.whoseTurn === townController.ourPlayer + ? 'your' + : gameAreaController.whoseTurn?.userName + "'s"}{' '} + turn + + ); + } else { + let joinGameButton = <>; + if ( + (gameAreaController.status === 'WAITING_TO_START' && !gameAreaController.isPlayer) || + gameAreaController.status === 'OVER' + ) { + joinGameButton = ( + + ); + } + gameStatusText = ( + + Game {gameStatus === 'WAITING_TO_START' ? 'not yet started' : 'over'}. {joinGameButton} + + ); + } + + return ( + + + + + + + Leaderboard + + + + + + + + + + + + + Current Observers + + + + + + + {observers.map(player => { + return {player.userName}; + })} + + + + + {gameStatusText} + + X: {x?.userName || '(No player yet!)'} + O: {o?.userName || '(No player yet!)'} + + + + ); +} + +/** + * A wrapper component for the TicTacToeArea component. + * Determines if the player is currently in a tic tac toe area on the map, and if so, + * renders the TicTacToeArea component in a modal. + * + */ +export default function TicTacToeAreaWrapper(): JSX.Element { + const gameArea = useInteractable('gameArea'); + const townController = useTownController(); + const closeModal = useCallback(() => { + if (gameArea) { + townController.interactEnd(gameArea); + const controller = townController.getGameAreaController(gameArea); + controller.leaveGame(); + } + }, [townController, gameArea]); + + if (gameArea && gameArea.getData('type') === 'TicTacToe') { + return ( + + + + {gameArea.name} + + ; + + + ); + } + return <>; +} diff --git a/frontend/src/components/Town/interactables/TicTacToe/TicTacToeBoard.test.tsx b/frontend/src/components/Town/interactables/TicTacToe/TicTacToeBoard.test.tsx new file mode 100644 index 000000000..d65a57745 --- /dev/null +++ b/frontend/src/components/Town/interactables/TicTacToe/TicTacToeBoard.test.tsx @@ -0,0 +1,291 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import TicTacToeBoard from './TicTacToeBoard'; +import TicTacToeAreaController, { + TicTacToeCell, +} from '../../../../classes/interactable/TicTacToeAreaController'; +import { mock } from 'jest-mock-extended'; +import { nanoid } from 'nanoid'; +import React from 'react'; +import { GameArea, GameStatus, TicTacToeGameState } from '../../../../types/CoveyTownSocket'; +import TownController from '../../../../classes/TownController'; +import PlayerController from '../../../../classes/PlayerController'; +import { act } from 'react-dom/test-utils'; + +const mockToast = jest.fn(); +jest.mock('@chakra-ui/react', () => { + const ui = jest.requireActual('@chakra-ui/react'); + const mockUseToast = () => mockToast; + return { + ...ui, + useToast: mockUseToast, + }; +}); + +class MockTicTacToeAreaController extends TicTacToeAreaController { + makeMove = jest.fn(); + + mockBoard: TicTacToeCell[][] = [ + [undefined, undefined, undefined], + [undefined, undefined, undefined], + [undefined, undefined, undefined], + ]; + + mockIsPlayer = false; + + mockIsOurTurn = false; + + public constructor() { + super(nanoid(), mock>(), mock()); + } + + /* + For ease of testing, we will mock the board property + to return a copy of the mockBoard property, so that + we can change the mockBoard property and then check + that the board property is updated correctly. + */ + get board() { + const copy = this.mockBoard.concat([]); + for (let i = 0; i < 3; i++) { + copy[i] = copy[i].concat([]); + } + return copy; + } + + get isOurTurn() { + return this.mockIsOurTurn; + } + + get x(): PlayerController | undefined { + throw new Error('Method should not be called within this component.'); + } + + get o(): PlayerController | undefined { + throw new Error('Method should not be called within this component.'); + } + + get observers(): PlayerController[] { + throw new Error('Method should not be called within this component.'); + } + + get moveCount(): number { + throw new Error('Method should not be called within this component.'); + } + + get winner(): PlayerController | undefined { + throw new Error('Method should not be called within this component.'); + } + + get whoseTurn(): PlayerController | undefined { + throw new Error('Method should not be called within this component.'); + } + + get status(): GameStatus { + throw new Error('Method should not be called within this component.'); + } + + get isPlayer() { + return this.mockIsPlayer; + } + + get gamePiece(): 'X' | 'O' { + throw new Error('Method should not be called within this component.'); + } + + public isActive(): boolean { + throw new Error('Method should not be called within this component.'); + } + + public mockReset() { + this.mockBoard = [ + ['X', 'O', undefined], + [undefined, 'X', undefined], + [undefined, undefined, 'O'], + ]; + this.makeMove.mockReset(); + } +} +describe('TicTacToeBoard', () => { + // Spy on console.error and intercept react key warnings to fail test + let consoleErrorSpy: jest.SpyInstance; + beforeAll(() => { + // Spy on console.error and intercept react key warnings to fail test + consoleErrorSpy = jest.spyOn(global.console, 'error'); + consoleErrorSpy.mockImplementation((message?, ...optionalParams) => { + const stringMessage = message as string; + if (stringMessage.includes && stringMessage.includes('children with the same key,')) { + throw new Error(stringMessage.replace('%s', optionalParams[0])); + } else if (stringMessage.includes && stringMessage.includes('warning-keys')) { + throw new Error(stringMessage.replace('%s', optionalParams[0])); + } + // eslint-disable-next-line no-console -- we are wrapping the console with a spy to find react warnings + console.warn(message, ...optionalParams); + }); + }); + afterAll(() => { + consoleErrorSpy.mockRestore(); + }); + + const gameAreaController = new MockTicTacToeAreaController(); + beforeEach(() => { + gameAreaController.mockReset(); + mockToast.mockReset(); + }); + async function checkBoard({ + clickable, + checkMakeMove, + checkToast, + }: { + clickable?: boolean; + checkMakeMove?: boolean; + checkToast?: boolean; + }) { + const cells = screen.getAllByRole('button'); + // There should be exactly 9 buttons: one per-cell (and no other buttons in this component) + expect(cells).toHaveLength(9); + // Each cell should have the correct aria-label + for (let i = 0; i < 9; i++) { + expect(cells[i]).toHaveAttribute('aria-label', `Cell ${Math.floor(i / 3)},${i % 3}`); + } + // Each cell should have the correct text content + for (let i = 0; i < 9; i++) { + const cell = gameAreaController.board[Math.floor(i / 3)][i % 3]; + expect(cells[i]).toHaveTextContent(cell ? cell : ''); + } + if (clickable) { + // Each cell should be clickable if it is the player's turn + for (let i = 0; i < 9; i++) { + expect(cells[i]).toBeEnabled(); + gameAreaController.makeMove.mockReset(); + mockToast.mockClear(); + + fireEvent.click(cells[i]); + if (checkMakeMove) { + expect(gameAreaController.makeMove).toBeCalledWith(Math.floor(i / 3), i % 3); + if (checkToast) { + gameAreaController.makeMove.mockClear(); + expect(mockToast).not.toBeCalled(); + mockToast.mockClear(); + const expectedMessage = `Invalid Move ${nanoid()}}`; + gameAreaController.makeMove.mockRejectedValue(new Error(expectedMessage)); + fireEvent.click(cells[i]); + await waitFor(() => { + expect(mockToast).toBeCalledWith( + expect.objectContaining({ + status: 'error', + description: `Error: ${expectedMessage}`, + }), + ); + }); + } + } + } + } else { + // Each cell should be disabled if it is not the player's turn + for (let i = 0; i < 9; i++) { + expect(cells[i]).toBeDisabled(); + } + } + } + describe('[T3.1] When observing the game', () => { + beforeEach(() => { + gameAreaController.mockIsPlayer = false; + }); + it('renders the board with the correct number of cells', async () => { + render(); + const cells = screen.getAllByRole('button'); + // There should be exactly 9 buttons: one per-cell (and no other buttons in this component) + expect(cells).toHaveLength(9); + // Each cell should have the correct aria-label + for (let i = 0; i < 9; i++) { + expect(cells[i]).toHaveAttribute('aria-label', `Cell ${Math.floor(i / 3)},${i % 3}`); + } + // Each cell should have the correct text content + expect(cells[0]).toHaveTextContent('X'); + expect(cells[1]).toHaveTextContent('O'); + expect(cells[2]).toHaveTextContent(''); + expect(cells[3]).toHaveTextContent(''); + expect(cells[4]).toHaveTextContent('X'); + expect(cells[5]).toHaveTextContent(''); + expect(cells[6]).toHaveTextContent(''); + expect(cells[7]).toHaveTextContent(''); + expect(cells[8]).toHaveTextContent('O'); + }); + it('does not make a move when a cell is clicked, and cell is disabled', async () => { + render(); + const cells = screen.getAllByRole('button'); + for (let i = 0; i < 9; i++) { + expect(cells[i]).toBeDisabled(); + fireEvent.click(cells[i]); + expect(gameAreaController.makeMove).not.toHaveBeenCalled(); + expect(mockToast).not.toHaveBeenCalled(); + } + }); + it('updates the board displayed in response to boardChanged events', async () => { + render(); + gameAreaController.mockBoard = [ + ['O', 'X', 'O'], + ['X', 'O', 'X'], + ['O', 'X', 'O'], + ]; + act(() => { + gameAreaController.emit('boardChanged', gameAreaController.mockBoard); + }); + await checkBoard({}); + gameAreaController.mockBoard = [ + ['X', 'O', 'X'], + [undefined, undefined, 'X'], + ['O', 'X', undefined], + ]; + act(() => { + gameAreaController.emit('boardChanged', gameAreaController.mockBoard); + }); + await checkBoard({}); + }); + }); + describe('[T3.2] When playing the game', () => { + beforeEach(() => { + gameAreaController.mockIsPlayer = true; + gameAreaController.mockIsOurTurn = true; + }); + it("enables cells when it is the player's turn", async () => { + render(); + await checkBoard({ clickable: true }); + gameAreaController.mockIsOurTurn = false; + act(() => { + gameAreaController.emit('turnChanged', gameAreaController.mockIsOurTurn); + }); + await checkBoard({ clickable: false }); + }); + it('makes a move when a cell is clicked', async () => { + render(); + await checkBoard({ clickable: true, checkMakeMove: true }); + }); + it('displays an error toast when an invalid move is made', async () => { + render(); + await checkBoard({ clickable: true, checkMakeMove: true, checkToast: true }); + }); + it('updates the board in response to boardChanged events', async () => { + render(); + await checkBoard({ clickable: true }); + gameAreaController.mockBoard = [ + ['O', 'X', 'O'], + ['X', 'O', 'X'], + ['O', 'X', 'O'], + ]; + act(() => { + gameAreaController.emit('boardChanged', gameAreaController.mockBoard); + }); + await checkBoard({ clickable: true }); + }); + it("disables cells when it is not the player's turn", async () => { + render(); + await checkBoard({ clickable: true }); + gameAreaController.mockIsOurTurn = false; + act(() => { + gameAreaController.emit('turnChanged', gameAreaController.mockIsOurTurn); + }); + await checkBoard({ clickable: false }); + }); + }); +}); diff --git a/frontend/src/components/Town/interactables/TicTacToe/TicTacToeBoard.tsx b/frontend/src/components/Town/interactables/TicTacToe/TicTacToeBoard.tsx new file mode 100644 index 000000000..7a90263a8 --- /dev/null +++ b/frontend/src/components/Town/interactables/TicTacToe/TicTacToeBoard.tsx @@ -0,0 +1,101 @@ +import { Button, chakra, Container, useToast } from '@chakra-ui/react'; +import React, { useEffect, useState } from 'react'; +import TicTacToeAreaController, { + TicTacToeCell, +} from '../../../../classes/interactable/TicTacToeAreaController'; +import { TicTacToeGridPosition } from '../../../../types/CoveyTownSocket'; + +export type TicTacToeGameProps = { + gameAreaController: TicTacToeAreaController; +}; + +/** + * A component that will render a single cell in the TicTacToe board, styled + */ +const StyledTicTacToeSquare = chakra(Button, { + baseStyle: { + justifyContent: 'center', + alignItems: 'center', + flexBasis: '33%', + border: '1px solid black', + height: '33%', + fontSize: '50px', + _disabled: { + opacity: '100%', + }, + }, +}); +/** + * A component that will render the TicTacToe board, styled + */ +const StyledTicTacToeBoard = chakra(Container, { + baseStyle: { + display: 'flex', + width: '400px', + height: '400px', + padding: '5px', + flexWrap: 'wrap', + }, +}); + +/** + * A component that renders the TicTacToe board + * + * Renders the TicTacToe board as a "StyledTicTacToeBoard", which consists of 9 "StyledTicTacToeSquare"s + * (one for each cell in the board, starting from the top left and going left to right, top to bottom). + * Each StyledTicTacToeSquare has an aria-label property that describes the cell's position in the board, + * formatted as `Cell ${rowIndex},${colIndex}`. + * + * The board is re-rendered whenever the board changes, and each cell is re-rendered whenever the value + * of that cell changes. + * + * If the current player is in the game, then each StyledTicTacToeSquare is clickable, and clicking + * on it will make a move in that cell. If there is an error making the move, then a toast will be + * displayed with the error message as the description of the toast. If it is not the current player's + * turn, then the StyledTicTacToeSquare will be disabled. + * + * @param gameAreaController the controller for the TicTacToe game + */ +export default function TicTacToeBoard({ gameAreaController }: TicTacToeGameProps): JSX.Element { + const [board, setBoard] = useState(gameAreaController.board); + const [isOurTurn, setIsOurTurn] = useState(gameAreaController.isOurTurn); + const toast = useToast(); + useEffect(() => { + gameAreaController.addListener('turnChanged', setIsOurTurn); + gameAreaController.addListener('boardChanged', setBoard); + return () => { + gameAreaController.removeListener('boardChanged', setBoard); + gameAreaController.removeListener('turnChanged', setIsOurTurn); + }; + }, [gameAreaController]); + return ( + + {board.map((row, rowIndex) => { + return row.map((cell, colIndex) => { + return ( + { + try { + await gameAreaController.makeMove( + rowIndex as TicTacToeGridPosition, + colIndex as TicTacToeGridPosition, + ); + } catch (e) { + toast({ + title: 'Error making move', + description: (e as Error).toString(), + status: 'error', + }); + } + }} + disabled={!isOurTurn} + aria-label={`Cell ${rowIndex},${colIndex}`}> + {cell} + + ); + }); + })} + + ); +} diff --git a/frontend/src/components/Town/interactables/ViewingArea.ts b/frontend/src/components/Town/interactables/ViewingArea.ts index dec51745c..49c78e9f5 100644 --- a/frontend/src/components/Town/interactables/ViewingArea.ts +++ b/frontend/src/components/Town/interactables/ViewingArea.ts @@ -27,7 +27,6 @@ export default class ViewingArea extends Interactable { { color: '#FFFFFF', backgroundColor: '#000000' }, ); this._labelText.setVisible(false); - this.townController.getViewingAreaController(this); this.setDepth(-1); } diff --git a/frontend/src/components/Town/interactables/ViewingAreaVideo.test.tsx b/frontend/src/components/Town/interactables/ViewingAreaVideo.test.tsx index 39efbee81..61e038c35 100644 --- a/frontend/src/components/Town/interactables/ViewingAreaVideo.test.tsx +++ b/frontend/src/components/Town/interactables/ViewingAreaVideo.test.tsx @@ -6,7 +6,9 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; import * as ReactPlayer from 'react-player'; import TownController from '../../../classes/TownController'; -import ViewingAreaController, { ViewingAreaEvents } from '../../../classes/ViewingAreaController'; +import ViewingAreaController, { + ViewingAreaEvents, +} from '../../../classes/interactable/ViewingAreaController'; import TownControllerContext from '../../../contexts/TownControllerContext'; import { ViewingAreaVideo } from './ViewingAreaVideo'; @@ -103,6 +105,8 @@ describe('[T4] Viewing Area Video', () => { id: 'test', isPlaying: true, video: 'test', + occupants: [], + type: 'ViewingArea', }); addListenerSpy = jest.spyOn(viewingArea, 'addListener'); @@ -228,6 +232,8 @@ describe('[T4] Viewing Area Video', () => { id: 'test', isPlaying: true, video: 'test', + occupants: [], + type: 'ViewingArea', }); const newAddListenerSpy = jest.spyOn(newViewingArea, 'addListener'); renderData.rerender(renderViewingArea(newViewingArea, townController)); diff --git a/frontend/src/components/Town/interactables/ViewingAreaVideo.tsx b/frontend/src/components/Town/interactables/ViewingAreaVideo.tsx index f27f0e4aa..d5a04b1b2 100644 --- a/frontend/src/components/Town/interactables/ViewingAreaVideo.tsx +++ b/frontend/src/components/Town/interactables/ViewingAreaVideo.tsx @@ -1,8 +1,8 @@ import { Container } from '@chakra-ui/react'; import React, { useEffect, useRef, useState } from 'react'; import ReactPlayer from 'react-player'; -import { useInteractable, useViewingAreaController } from '../../../classes/TownController'; -import ViewingAreaController from '../../../classes/ViewingAreaController'; +import { useInteractable, useInteractableAreaController } from '../../../classes/TownController'; +import ViewingAreaController from '../../../classes/interactable/ViewingAreaController'; import useTownController from '../../../hooks/useTownController'; import SelectVideoModal from './SelectVideoModal'; import ViewingAreaInteractable from './ViewingArea'; @@ -119,7 +119,9 @@ export function ViewingArea({ viewingArea: ViewingAreaInteractable; }): JSX.Element { const townController = useTownController(); - const viewingAreaController = useViewingAreaController(viewingArea.name); + const viewingAreaController = useInteractableAreaController( + viewingArea.name, + ); const [selectIsOpen, setSelectIsOpen] = useState(viewingAreaController.video === undefined); const [viewingAreaVideoURL, setViewingAreaVideoURL] = useState(viewingAreaController.video); useEffect(() => { diff --git a/frontend/src/components/VideoCall/VideoFrontend/components/BackgroundSelectionDialog/BackgroundSelectionDialog.tsx b/frontend/src/components/VideoCall/VideoFrontend/components/BackgroundSelectionDialog/BackgroundSelectionDialog.tsx index 654f8a2cb..31c9a0270 100644 --- a/frontend/src/components/VideoCall/VideoFrontend/components/BackgroundSelectionDialog/BackgroundSelectionDialog.tsx +++ b/frontend/src/components/VideoCall/VideoFrontend/components/BackgroundSelectionDialog/BackgroundSelectionDialog.tsx @@ -37,7 +37,7 @@ function BackgroundSelectionDialog() { paper: classes.drawer, }} > - setIsBackgroundSelectionOpen(false)} /> + {/* setIsBackgroundSelectionOpen(false)} />
@@ -50,7 +50,7 @@ function BackgroundSelectionDialog() { key={image} /> ))} -
+
*/} ); } diff --git a/frontend/src/components/VideoCall/VideoFrontend/components/MenuBar/Menu/Menu.tsx b/frontend/src/components/VideoCall/VideoFrontend/components/MenuBar/Menu/Menu.tsx index c2a094b63..f7cf439c2 100644 --- a/frontend/src/components/VideoCall/VideoFrontend/components/MenuBar/Menu/Menu.tsx +++ b/frontend/src/components/VideoCall/VideoFrontend/components/MenuBar/Menu/Menu.tsx @@ -79,7 +79,7 @@ export default function Menu(props: { buttonClassName?: string }) { Audio and Video Settings - {isSupported && ( + {/* {isSupported && ( { setIsBackgroundSelectionOpen(true); @@ -92,7 +92,7 @@ export default function Menu(props: { buttonClassName?: string }) { Backgrounds - )} + )} */} {flipCameraSupported && ( diff --git a/frontend/src/components/VideoCall/VideoFrontend/components/ParticipantList/ParticipantList.tsx b/frontend/src/components/VideoCall/VideoFrontend/components/ParticipantList/ParticipantList.tsx index 25bfe337d..7ac8f9547 100644 --- a/frontend/src/components/VideoCall/VideoFrontend/components/ParticipantList/ParticipantList.tsx +++ b/frontend/src/components/VideoCall/VideoFrontend/components/ParticipantList/ParticipantList.tsx @@ -2,6 +2,7 @@ import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'; import clsx from 'clsx'; import React from 'react'; import { usePlayersInVideoCall } from '../../../../../classes/TownController'; +import TicTacToeAreaWrapper from '../../../../Town/interactables/TicTacToe/TicTacToeArea'; import ViewingAreaVideo from '../../../../Town/interactables/ViewingAreaVideo'; import useMainParticipant from '../../hooks/useMainParticipant/useMainParticipant'; import useParticipants, { ParticipantWithSlot } from '../../hooks/useParticipants/useParticipants'; @@ -63,9 +64,9 @@ const useStyles = makeStyles((theme: Theme) => // }, width: '100%', justifyContent: 'center', - alignContent: 'center' + alignContent: 'center', }, - }) + }), ); export default function ParticipantList() { @@ -77,7 +78,8 @@ export default function ParticipantList() { const screenShareParticipant = useScreenShareParticipant(); const mainParticipant = useMainParticipant(); const nearbyPlayers = usePlayersInVideoCall(); - const isRemoteParticipantScreenSharing = screenShareParticipant && screenShareParticipant !== localParticipant; + const isRemoteParticipantScreenSharing = + screenShareParticipant && screenShareParticipant !== localParticipant; const classes = useStyles('fullwidth'); // if (participants.length === 0) return null; // Don't render this component if there are no remote participants. @@ -120,26 +122,31 @@ export default function ParticipantList() { participant={localParticipant} isLocalParticipant insideGrid={true} - // highlight={highlightedProfiles?.includes(localUserProfile.id) ?? false} + // highlight={highlightedProfiles?.includes(localUserProfile.id) ?? false} slot={0} /> {participants - .filter((p) => nearbyPlayers.find((player) => player.id == p.participant.identity)) - .sort(participantSorter).map((participantWithSlot) => { + .filter(p => nearbyPlayers.find(player => player.id == p.participant.identity)) + .sort(participantSorter) + .map(participantWithSlot => { const { participant } = participantWithSlot; const isSelected = participant === selectedParticipant; - const hideParticipant = participant === mainParticipant - && participant !== screenShareParticipant - && !isSelected - && participants.length > 1; - const player = nearbyPlayers.find((p) => p.id == participantWithSlot.participant.identity); - const remoteProfile = { displayName: player ? player.userName : 'unknown', id: participantWithSlot.participant.identity }; + const hideParticipant = + participant === mainParticipant && + participant !== screenShareParticipant && + !isSelected && + participants.length > 1; + const player = nearbyPlayers.find(p => p.id == participantWithSlot.participant.identity); + const remoteProfile = { + displayName: player ? player.userName : 'unknown', + id: participantWithSlot.participant.identity, + }; return ( ); - return
{participantsEl}
+ ); } diff --git a/frontend/src/components/VideoCall/VideoFrontend/components/VideoProvider/useBackgroundSettings/useBackgroundSettings.ts b/frontend/src/components/VideoCall/VideoFrontend/components/VideoProvider/useBackgroundSettings/useBackgroundSettings.ts index 82f163ba8..ef8a26775 100644 --- a/frontend/src/components/VideoCall/VideoFrontend/components/VideoProvider/useBackgroundSettings/useBackgroundSettings.ts +++ b/frontend/src/components/VideoCall/VideoFrontend/components/VideoProvider/useBackgroundSettings/useBackgroundSettings.ts @@ -152,43 +152,43 @@ export default function useBackgroundSettings(videoTrack: LocalVideoTrack | unde [videoTrack, removeProcessor] ); - useEffect(() => { - if (!isSupported) { - return; - } - // make sure localParticipant has joined room before applying video processors - // this ensures that the video processors are not applied on the LocalVideoPreview - const handleProcessorChange = async () => { - if (!blurProcessor) { - blurProcessor = new GaussianBlurBackgroundProcessor({ - assetsPath: virtualBackgroundAssets, - }); - await blurProcessor.loadModel(); - } - if (!virtualBackgroundProcessor) { - virtualBackgroundProcessor = new VirtualBackgroundProcessor({ - assetsPath: virtualBackgroundAssets, - backgroundImage: await getImage(0), - fitType: ImageFit.Cover, - }); - await virtualBackgroundProcessor.loadModel(); - } - if (!room?.localParticipant) { - return; - } + // useEffect(() => { + // if (!isSupported) { + // return; + // } + // // make sure localParticipant has joined room before applying video processors + // // this ensures that the video processors are not applied on the LocalVideoPreview + // const handleProcessorChange = async () => { + // if (!blurProcessor) { + // blurProcessor = new GaussianBlurBackgroundProcessor({ + // assetsPath: virtualBackgroundAssets, + // }); + // await blurProcessor.loadModel(); + // } + // if (!virtualBackgroundProcessor) { + // virtualBackgroundProcessor = new VirtualBackgroundProcessor({ + // assetsPath: virtualBackgroundAssets, + // backgroundImage: await getImage(0), + // fitType: ImageFit.Cover, + // }); + // await virtualBackgroundProcessor.loadModel(); + // } + // if (!room?.localParticipant) { + // return; + // } - if (backgroundSettings.type === 'blur') { - addProcessor(blurProcessor); - } else if (backgroundSettings.type === 'image' && typeof backgroundSettings.index === 'number') { - virtualBackgroundProcessor.backgroundImage = await getImage(backgroundSettings.index); - addProcessor(virtualBackgroundProcessor); - } else { - removeProcessor(); - } - }; - handleProcessorChange(); - window.localStorage.setItem(SELECTED_BACKGROUND_SETTINGS_KEY, JSON.stringify(backgroundSettings)); - }, [backgroundSettings, videoTrack, room, addProcessor, removeProcessor]); + // if (backgroundSettings.type === 'blur') { + // addProcessor(blurProcessor); + // } else if (backgroundSettings.type === 'image' && typeof backgroundSettings.index === 'number') { + // virtualBackgroundProcessor.backgroundImage = await getImage(backgroundSettings.index); + // addProcessor(virtualBackgroundProcessor); + // } else { + // removeProcessor(); + // } + // }; + // handleProcessorChange(); + // window.localStorage.setItem(SELECTED_BACKGROUND_SETTINGS_KEY, JSON.stringify(backgroundSettings)); + // }, [backgroundSettings, videoTrack, room, addProcessor, removeProcessor]); return [backgroundSettings, setBackgroundSettings] as const; } diff --git a/frontend/src/hooks/hooks.test.tsx b/frontend/src/hooks/hooks.test.tsx index fb4d5c4d5..0337dd0b4 100644 --- a/frontend/src/hooks/hooks.test.tsx +++ b/frontend/src/hooks/hooks.test.tsx @@ -6,9 +6,12 @@ import { act } from 'react-dom/test-utils'; import ConversationAreaController, { ConversationAreaEvents, NO_TOPIC_STRING, - useConversationAreaOccupants, useConversationAreaTopic, -} from '../classes/ConversationAreaController'; +} from '../classes/interactable/ConversationAreaController'; +import { + BaseInteractableEventMap, + useInteractableAreaOccupants, +} from '../classes/interactable/InteractableAreaController'; import PlayerController from '../classes/PlayerController'; import TownController, { TownEvents, @@ -165,9 +168,9 @@ describe('[T3] TownController-Dependent Hooks', () => { }); it('Updates its value in response to conversationAreasChanged events', () => { act(() => { - const listener = getSingleListenerAdded('conversationAreasChanged'); + const listener = getSingleListenerAdded('interactableAreasChanged'); conversationAreas[2].occupants.push(players[2]); - listener(conversationAreas); + listener(); }); hookReturnValue.sort((a, b) => (a.topic && b.topic ? a.topic.localeCompare(b.topic) : 0)); expect(hookReturnValue).toEqual([ @@ -178,33 +181,33 @@ describe('[T3] TownController-Dependent Hooks', () => { }); it('Only adds a listener once', () => { // Check that there was one listener added - getSingleListenerAdded('conversationAreasChanged'); + getSingleListenerAdded('interactableAreasChanged'); // Trigger re-render act(() => { - const listener = getTownEventListener(townController, 'conversationAreasChanged'); + const listener = getTownEventListener(townController, 'interactableAreasChanged'); conversationAreas[2].occupants.push(players[2]); - listener(conversationAreas); + listener(); }); renderData.rerender(); // Should still be one - getSingleListenerAdded('conversationAreasChanged'); + getSingleListenerAdded('interactableAreasChanged'); }); it('Removes the listener when the component is unmounted', () => { - const addCall = getSingleListenerAdded('conversationAreasChanged'); + const addCall = getSingleListenerAdded('interactableAreasChanged'); cleanup(); - const removeCall = getSingleListenerRemoved('conversationAreasChanged'); + const removeCall = getSingleListenerRemoved('interactableAreasChanged'); expect(addCall).toBe(removeCall); }); it('Adds a listener on first render and does not re-register a listener on each render', () => { - getSingleListenerAdded('conversationAreasChanged'); + getSingleListenerAdded('interactableAreasChanged'); renderData.rerender(); renderData.rerender(); renderData.rerender(); - getSingleListenerAdded('conversationAreasChanged'); + getSingleListenerAdded('interactableAreasChanged'); }); it('Removes the listener if the townController changes and adds one to the new controller', () => { - const addCall = getSingleListenerAdded('conversationAreasChanged'); + const addCall = getSingleListenerAdded('interactableAreasChanged'); const newController = mockTownController({ friendlyName: nanoid(), townID: nanoid(), @@ -213,9 +216,9 @@ describe('[T3] TownController-Dependent Hooks', () => { useTownControllerSpy.mockReturnValue(newController); renderData.rerender(); - expect(getSingleListenerRemoved('conversationAreasChanged')).toBe(addCall); + expect(getSingleListenerRemoved('interactableAreasChanged')).toBe(addCall); - getSingleListenerAdded('conversationAreasChanged', newController.addListener); + getSingleListenerAdded('interactableAreasChanged', newController.addListener); }); }); @@ -318,16 +321,13 @@ describe('[T3] TownController-Dependent Hooks', () => { describe('ConversationAreaController hooks', () => { let conversationAreaController: ConversationAreaController; - type ConversationAreaEventName = keyof ConversationAreaEvents; - let addListenerSpy: jest.SpyInstance< ConversationAreaController, - [event: ConversationAreaEventName, listener: ConversationAreaEvents[ConversationAreaEventName]] + Parameters >; - let removeListenerSpy: jest.SpyInstance< ConversationAreaController, - [event: ConversationAreaEventName, listener: ConversationAreaEvents[ConversationAreaEventName]] + Parameters >; beforeEach(() => { @@ -335,21 +335,37 @@ describe('ConversationAreaController hooks', () => { addListenerSpy = jest.spyOn(conversationAreaController, 'addListener'); removeListenerSpy = jest.spyOn(conversationAreaController, 'removeListener'); }); - function getSingleListenerAdded>( + function getSingleListenerAdded< + Ev extends EventNames | EventNames, + >( eventName: Ev, spy = addListenerSpy, - ): ConversationAreaEvents[Ev] { + ): Ev extends EventNames + ? ConversationAreaEvents[Ev] + : Ev extends EventNames + ? BaseInteractableEventMap[Ev] + : never { const addedListeners = spy.mock.calls.filter(eachCall => eachCall[0] === eventName); if (addedListeners.length !== 1) { throw new Error( `Expected to find exactly one addListener call for ${eventName} but found ${addedListeners.length}`, ); } - return addedListeners[0][1] as unknown as ConversationAreaEvents[Ev]; + return addedListeners[0][1] as unknown as Ev extends EventNames + ? ConversationAreaEvents[Ev] + : Ev extends EventNames + ? BaseInteractableEventMap[Ev] + : never; } - function getSingleListenerRemoved>( + function getSingleListenerRemoved< + Ev extends EventNames | EventNames, + >( eventName: Ev, - ): ConversationAreaEvents[Ev] { + ): Ev extends EventNames + ? ConversationAreaEvents[Ev] + : Ev extends EventNames + ? BaseInteractableEventMap[Ev] + : never { const removedListeners = removeListenerSpy.mock.calls.filter( eachCall => eachCall[0] === eventName, ); @@ -358,14 +374,18 @@ describe('ConversationAreaController hooks', () => { `Expected to find exactly one removeListeners call for ${eventName} but found ${removedListeners.length}`, ); } - return removedListeners[0][1] as unknown as ConversationAreaEvents[Ev]; + return removedListeners[0][1] as unknown as Ev extends EventNames + ? ConversationAreaEvents[Ev] + : Ev extends EventNames + ? BaseInteractableEventMap[Ev] + : never; } describe('[T3] useConversationAreaOccupants', () => { let hookReturnValue: PlayerController[]; let testPlayers: PlayerController[]; let renderData: RenderResult; function TestComponent(props: { controller?: ConversationAreaController }) { - hookReturnValue = useConversationAreaOccupants( + hookReturnValue = useInteractableAreaOccupants( props.controller || conversationAreaController, ); return null; diff --git a/frontend/src/types/TypeUtils.ts b/frontend/src/types/TypeUtils.ts index d2136ca4d..f875bbb07 100644 --- a/frontend/src/types/TypeUtils.ts +++ b/frontend/src/types/TypeUtils.ts @@ -1,15 +1,27 @@ -import { ConversationArea, Interactable, ViewingArea } from './CoveyTownSocket'; +import { + ConversationArea, + Interactable, + TicTacToeGameState, + ViewingArea, + GameArea, +} from './CoveyTownSocket'; /** * Test to see if an interactable is a conversation area */ export function isConversationArea(interactable: Interactable): interactable is ConversationArea { - return 'occupantsByID' in interactable; + return interactable.type === 'ConversationArea'; } /** * Test to see if an interactable is a viewing area */ export function isViewingArea(interactable: Interactable): interactable is ViewingArea { - return 'isPlaying' in interactable; + return interactable.type === 'ViewingArea'; +} + +export function isTicTacToeArea( + interactable: Interactable, +): interactable is GameArea { + return interactable.type === 'TicTacToeArea'; } diff --git a/package-lock.json b/package-lock.json index a876021d5..392901ace 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,16 +22,14 @@ "eslint-plugin-jest": "^24.1.3", "eslint-plugin-react": "^7.21.5", "eslint-plugin-react-hooks": "^4.2.0", - "husky": "^4.3.0", - "lint-staged": "^10.5.1", "npm-pack-zip": "^1.3.0", "prettier": "^2.1.2", "prettier-plugin-organize-imports": "^1.1.1", - "typescript": "^4.1.2" + "typescript": "^4.9.5" }, "engines": { "node": "18.x.x", - "npm": "8.x.x" + "npm": "9.x.x" } }, "node_modules/@babel/code-frame": { @@ -249,12 +247,6 @@ "integrity": "sha512-cHYfKsnwllYhjOzuC5q1VpguABBeecUp24yFluHpn/BQaVxB1CuQ1FSRZCzrPxrkIfWISXV2LbeoBthLWg0+0A==", "dev": true }, - "node_modules/@types/parse-json": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz", - "integrity": "sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==", - "dev": true - }, "node_modules/@types/ramda": { "version": "0.27.34", "resolved": "https://registry.npmjs.org/@types/ramda/-/ramda-0.27.34.tgz", @@ -401,19 +393,6 @@ "integrity": "sha512-K0Ptm/47OKfQRpNQ2J/oIN/3QYiK6FwW+eJbILhsdxh2WTLdl+30o8aGdTbm5JbffpFFAg/g+zi1E+jvJha5ng==", "dev": true }, - "node_modules/aggregate-error": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", - "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", - "dev": true, - "dependencies": { - "clean-stack": "^2.0.0", - "indent-string": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -435,27 +414,6 @@ "node": ">=6" } }, - "node_modules/ansi-escapes": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.1.tgz", - "integrity": "sha512-JWF7ocqNrp8u9oqpgV+wH5ftbt+cfvv+PTjOvKLT3AdYly/LmORARfEVT1iyjwN+4MqE5UmVKoAdIBqeoCHgLA==", - "dev": true, - "dependencies": { - "type-fest": "^0.11.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/ansi-escapes/node_modules/type-fest": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.11.0.tgz", - "integrity": "sha512-OdjXJxnCN1AvyLSzeKIgXTXxV+99ZuXl3Hpo9XpJAv9MBcHrrJOQ5kV7ypXOuQie+AmWG25hLbiKdwYTifzcfQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -762,60 +720,6 @@ "node": ">=10" } }, - "node_modules/ci-info": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-2.0.0.tgz", - "integrity": "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==", - "dev": true - }, - "node_modules/clean-stack": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", - "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/cli-cursor": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", - "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", - "dev": true, - "dependencies": { - "restore-cursor": "^3.1.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/cli-truncate": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-2.1.0.tgz", - "integrity": "sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==", - "dev": true, - "dependencies": { - "slice-ansi": "^3.0.0", - "string-width": "^4.2.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/cli-truncate/node_modules/slice-ansi": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-3.0.0.tgz", - "integrity": "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.0.0", - "astral-regex": "^2.0.0", - "is-fullwidth-code-point": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/cliui": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/cliui/-/cliui-4.1.0.tgz", @@ -957,21 +861,6 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true }, - "node_modules/commander": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.1.tgz", - "integrity": "sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==", - "dev": true, - "engines": { - "node": ">= 6" - } - }, - "node_modules/compare-versions": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/compare-versions/-/compare-versions-3.6.0.tgz", - "integrity": "sha512-W6Af2Iw1z4CB7q4uU4hv646dW9GQuBM+YpC0UvUCWSD8w90SJjp+ujJuXaEMtAXBtSqGfMPuFOVn4/+FlaqfBA==", - "dev": true - }, "node_modules/compress-commons": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-1.2.2.tgz", @@ -1011,22 +900,6 @@ "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", "dev": true }, - "node_modules/cosmiconfig": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.0.0.tgz", - "integrity": "sha512-pondGvTuVYDk++upghXJabWzL6Kxu6f26ljFw64Swq9v6sQPUL3EUlVDV56diOjpCayKihL6hVe8exIACU4XcA==", - "dev": true, - "dependencies": { - "@types/parse-json": "^4.0.0", - "import-fresh": "^3.2.1", - "parse-json": "^5.0.0", - "path-type": "^4.0.0", - "yaml": "^1.10.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/crc": { "version": "3.8.0", "resolved": "https://registry.npmjs.org/crc/-/crc-3.8.0.tgz", @@ -1084,12 +957,6 @@ "node": ">=0.10.0" } }, - "node_modules/dedent": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/dedent/-/dedent-0.7.0.tgz", - "integrity": "sha1-JJXduvbrh0q7Dhvp3yLS5aVEMmw=", - "dev": true - }, "node_modules/deep-is": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.3.tgz", @@ -1168,15 +1035,6 @@ "node": ">=8.6" } }, - "node_modules/error-ex": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", - "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", - "dev": true, - "dependencies": { - "is-arrayish": "^0.2.1" - } - }, "node_modules/es-abstract": { "version": "1.18.0-next.1", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.18.0-next.1.tgz", @@ -1477,26 +1335,6 @@ "node": ">=0.10.0" } }, - "node_modules/execa": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/execa/-/execa-4.1.0.tgz", - "integrity": "sha512-j5W0//W7f8UxAn8hXVnwG8tLwdiUy4FJLcSupCg6maBYZDpyBvTApK7KyuI4bKj8KOh1r2YH+6ucuYtJv1bTZA==", - "dev": true, - "dependencies": { - "cross-spawn": "^7.0.0", - "get-stream": "^5.0.0", - "human-signals": "^1.1.1", - "is-stream": "^2.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^4.0.0", - "onetime": "^5.1.0", - "signal-exit": "^3.0.2", - "strip-final-newline": "^2.0.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -1541,18 +1379,6 @@ "reusify": "^1.0.4" } }, - "node_modules/figures": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", - "integrity": "sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==", - "dev": true, - "dependencies": { - "escape-string-regexp": "^1.0.5" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/file-entry-cache": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.0.tgz", @@ -1577,31 +1403,6 @@ "node": ">=8" } }, - "node_modules/find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "dev": true, - "dependencies": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/find-versions": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/find-versions/-/find-versions-4.0.0.tgz", - "integrity": "sha512-wgpWy002tA+wgmO27buH/9KzyEOQnKsG/R0yrcjPT9BOFm0zRBVQbZ95nRGXWMywS8YR5knRbpohio0bcJABxQ==", - "dev": true, - "dependencies": { - "semver-regex": "^3.1.2" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/flat-cache": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.0.4.tgz", @@ -1673,24 +1474,6 @@ "has-symbols": "^1.0.1" } }, - "node_modules/get-own-enumerable-property-symbols": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/get-own-enumerable-property-symbols/-/get-own-enumerable-property-symbols-3.0.2.tgz", - "integrity": "sha512-I0UBV/XOz1XkIJHEUDMZAbzCThU/H8DxmSfmdGcKPnVhu2VfFqr34jr9777IyaTYvxjedWhqVIilEDsCdP5G6g==", - "dev": true - }, - "node_modules/get-stream": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", - "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", - "dev": true, - "dependencies": { - "pump": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/glob": { "version": "7.1.6", "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz", @@ -1785,41 +1568,6 @@ "node": ">= 0.4" } }, - "node_modules/human-signals": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-1.1.1.tgz", - "integrity": "sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw==", - "dev": true, - "engines": { - "node": ">=8.12.0" - } - }, - "node_modules/husky": { - "version": "4.3.8", - "resolved": "https://registry.npmjs.org/husky/-/husky-4.3.8.tgz", - "integrity": "sha512-LCqqsB0PzJQ/AlCgfrfzRe3e3+NvmefAdKQhRYpxS4u6clblBoDdzzvHi8fmxKRzvMxPY/1WZWzomPZww0Anow==", - "dev": true, - "hasInstallScript": true, - "dependencies": { - "chalk": "^4.0.0", - "ci-info": "^2.0.0", - "compare-versions": "^3.6.0", - "cosmiconfig": "^7.0.0", - "find-versions": "^4.0.0", - "opencollective-postinstall": "^2.0.2", - "pkg-dir": "^5.0.0", - "please-upgrade-node": "^3.2.0", - "slash": "^3.0.0", - "which-pm-runs": "^1.0.0" - }, - "bin": { - "husky-run": "bin/run.js", - "husky-upgrade": "lib/upgrader/bin.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -1880,15 +1628,6 @@ "node": ">=0.8.19" } }, - "node_modules/indent-string": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", - "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", - "dev": true, - "engines": { - "node": ">=8" - } - }, "node_modules/inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", @@ -1950,12 +1689,6 @@ "node": ">=4" } }, - "node_modules/is-arrayish": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", - "integrity": "sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0=", - "dev": true - }, "node_modules/is-callable": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.2.tgz", @@ -2031,15 +1764,6 @@ "node": ">=0.12.0" } }, - "node_modules/is-obj": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-1.0.1.tgz", - "integrity": "sha1-PkcprB9f3gJc19g6iW2rn09n2w8=", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/is-regex": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.1.tgz", @@ -2052,24 +1776,6 @@ "node": ">= 0.4" } }, - "node_modules/is-regexp": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-regexp/-/is-regexp-1.0.0.tgz", - "integrity": "sha1-/S2INUXEa6xaYz57mgnof6LLUGk=", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.0.tgz", - "integrity": "sha512-XCoy+WlUr7d1+Z8GgSuXmpuUFC9fOhRXglJMx+dwLKTkL44Cjd4W1Z5P+BQZpr+cR93aGP4S/s7Ftw6Nd/kiEw==", - "dev": true, - "engines": { - "node": ">=8" - } - }, "node_modules/is-string": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.5.tgz", @@ -2146,12 +1852,6 @@ "js-yaml": "bin/js-yaml.js" } }, - "node_modules/json-parse-even-better-errors": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", - "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "dev": true - }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -2223,102 +1923,12 @@ "node": ">= 0.8.0" } }, - "node_modules/lines-and-columns": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.1.6.tgz", - "integrity": "sha1-HADHQ7QzzQpOgHWPe2SldEDZ/wA=", - "dev": true - }, - "node_modules/lint-staged": { - "version": "10.5.3", - "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-10.5.3.tgz", - "integrity": "sha512-TanwFfuqUBLufxCc3RUtFEkFraSPNR3WzWcGF39R3f2J7S9+iF9W0KTVLfSy09lYGmZS5NDCxjNvhGMSJyFCWg==", - "dev": true, - "dependencies": { - "chalk": "^4.1.0", - "cli-truncate": "^2.1.0", - "commander": "^6.2.0", - "cosmiconfig": "^7.0.0", - "debug": "^4.2.0", - "dedent": "^0.7.0", - "enquirer": "^2.3.6", - "execa": "^4.1.0", - "listr2": "^3.2.2", - "log-symbols": "^4.0.0", - "micromatch": "^4.0.2", - "normalize-path": "^3.0.0", - "please-upgrade-node": "^3.2.0", - "string-argv": "0.3.1", - "stringify-object": "^3.3.0" - }, - "bin": { - "lint-staged": "bin/lint-staged.js" - } - }, - "node_modules/listr2": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/listr2/-/listr2-3.2.3.tgz", - "integrity": "sha512-vUb80S2dSUi8YxXahO8/I/s29GqnOL8ozgHVLjfWQXa03BNEeS1TpBLjh2ruaqq5ufx46BRGvfymdBSuoXET5w==", - "dev": true, - "dependencies": { - "chalk": "^4.1.0", - "cli-truncate": "^2.1.0", - "figures": "^3.2.0", - "indent-string": "^4.0.0", - "log-update": "^4.0.0", - "p-map": "^4.0.0", - "rxjs": "^6.6.3", - "through": "^2.3.8" - }, - "engines": { - "node": ">=10.0.0" - } - }, - "node_modules/locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "dev": true, - "dependencies": { - "p-locate": "^5.0.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/lodash": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "dev": true }, - "node_modules/log-symbols": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.0.0.tgz", - "integrity": "sha512-FN8JBzLx6CzeMrB0tg6pqlGU1wCrXW+ZXGH481kfsBqer0hToTIiHdjH4Mq8xJUbvATujKCvaREGWpGUionraA==", - "dev": true, - "dependencies": { - "chalk": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/log-update": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/log-update/-/log-update-4.0.0.tgz", - "integrity": "sha512-9fkkDevMefjg0mmzWFBW8YkFP91OrizzkW3diF7CpG+S2EYdy4+TVfGwz1zeF8x7hCx1ovSPTOE9Ngib74qqUg==", - "dev": true, - "dependencies": { - "ansi-escapes": "^4.3.0", - "cli-cursor": "^3.1.0", - "slice-ansi": "^4.0.0", - "wrap-ansi": "^6.2.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -2369,12 +1979,6 @@ "node": ">=6" } }, - "node_modules/merge-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", - "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", - "dev": true - }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -2452,15 +2056,6 @@ "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==", "dev": true }, - "node_modules/normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/npm-bundled": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/npm-bundled/-/npm-bundled-1.1.2.tgz", @@ -2504,18 +2099,6 @@ "npm-normalize-package-bin": "^1.0.1" } }, - "node_modules/npm-run-path": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", - "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", - "dev": true, - "dependencies": { - "path-key": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/number-is-nan": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz", @@ -2618,27 +2201,6 @@ "wrappy": "1" } }, - "node_modules/onetime": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", - "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", - "dev": true, - "dependencies": { - "mimic-fn": "^2.1.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/opencollective-postinstall": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/opencollective-postinstall/-/opencollective-postinstall-2.0.3.tgz", - "integrity": "sha512-8AV/sCtuzUeTo8gQK5qDZzARrulB3egtLzFgteqB2tcT4Mw7B8Kt7JcDHmltjz6FOAHsvTevk70gZEbhM4ZS9Q==", - "dev": true, - "bin": { - "opencollective-postinstall": "index.js" - } - }, "node_modules/optionator": { "version": "0.9.1", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz", @@ -2815,42 +2377,6 @@ "node": ">=6" } }, - "node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, - "dependencies": { - "yocto-queue": "^0.1.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "dev": true, - "dependencies": { - "p-limit": "^3.0.2" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/p-map": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", - "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==", - "dev": true, - "dependencies": { - "aggregate-error": "^3.0.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/p-try": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/p-try/-/p-try-1.0.0.tgz", @@ -2872,30 +2398,6 @@ "node": ">=6" } }, - "node_modules/parse-json": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.1.0.tgz", - "integrity": "sha512-+mi/lmVVNKFNVyLXV31ERiy2CY5E1/F6QtJFEzoChPRwwngMNXRDQ9GJ5WdE2Z2P4AujsOi0/+2qHID68KwfIQ==", - "dev": true, - "dependencies": { - "@babel/code-frame": "^7.0.0", - "error-ex": "^1.3.1", - "json-parse-even-better-errors": "^2.3.0", - "lines-and-columns": "^1.1.6" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, - "engines": { - "node": ">=8" - } - }, "node_modules/path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -2938,27 +2440,6 @@ "node": ">=8.6" } }, - "node_modules/pkg-dir": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-5.0.0.tgz", - "integrity": "sha512-NPE8TDbzl/3YQYY7CSS228s3g2ollTFnc+Qi3tqmqJp9Vg2ovUpixcJEo2HJScN2Ez+kEaal6y70c0ehqJBJeA==", - "dev": true, - "dependencies": { - "find-up": "^5.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/please-upgrade-node": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/please-upgrade-node/-/please-upgrade-node-3.2.0.tgz", - "integrity": "sha512-gQR3WpIgNIKwBMVLkpMUeR3e1/E1y42bqDQZfql+kDeXd8COYfM8PQA4X6y7a8u9Ua9FHmsrrmirW2vHs45hWg==", - "dev": true, - "dependencies": { - "semver-compare": "^1.0.0" - } - }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -3155,19 +2636,6 @@ "node": ">=4" } }, - "node_modules/restore-cursor": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", - "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", - "dev": true, - "dependencies": { - "onetime": "^5.1.0", - "signal-exit": "^3.0.2" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/reusify": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", @@ -3196,18 +2664,6 @@ "integrity": "sha512-zb/1OuZ6flOlH6tQyMPUrE3x3Ulxjlo9WIVXR4yVYi4H9UXQaeIsPbLn2R3O3vQCnDKkAl2qHiuocKKX4Tz/Sw==", "dev": true }, - "node_modules/rxjs": { - "version": "6.6.3", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.6.3.tgz", - "integrity": "sha512-trsQc+xYYXZ3urjOiJOuCOa5N3jAZ3eiSpQB5hIT8zGlL2QfnHLJ2r7GMkBGuIausdJN1OneaI6gQlsqNHHmZQ==", - "dev": true, - "dependencies": { - "tslib": "^1.9.0" - }, - "engines": { - "npm": ">=2.0.0" - } - }, "node_modules/safe-buffer": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", @@ -3238,24 +2694,6 @@ "node": ">=10" } }, - "node_modules/semver-compare": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz", - "integrity": "sha1-De4hahyUGrN+nvsXiPavxf9VN/w=", - "dev": true - }, - "node_modules/semver-regex": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/semver-regex/-/semver-regex-3.1.3.tgz", - "integrity": "sha512-Aqi54Mk9uYTjVexLnR67rTyBusmwd04cLkHy9hNvk3+G3nT2Oyg7E0l4XVbOaNwIvQ3hHeYxGcyEy+mKreyBFQ==", - "dev": true, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/set-blocking": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", @@ -3338,15 +2776,6 @@ "safe-buffer": "~5.1.0" } }, - "node_modules/string-argv": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.1.tgz", - "integrity": "sha512-a1uQGz7IyVy9YwhqjZIZu1c8JO8dNIe20xBmSS6qu9kv++k3JGzCVmprbNN5Kn+BgzD5E7YYwg1CcjuJMRNsvg==", - "dev": true, - "engines": { - "node": ">=0.6.19" - } - }, "node_modules/string-width": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.0.tgz", @@ -3396,20 +2825,6 @@ "define-properties": "^1.1.3" } }, - "node_modules/stringify-object": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/stringify-object/-/stringify-object-3.3.0.tgz", - "integrity": "sha512-rHqiFh1elqCQ9WPLIC8I0Q/g/wj5J1eMkyoiD6eoQApWHP0FtlK7rqnhmabL5VUY9JQCcqwwvlOaSuutekgyrw==", - "dev": true, - "dependencies": { - "get-own-enumerable-property-symbols": "^3.0.0", - "is-obj": "^1.0.1", - "is-regexp": "^1.0.0" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/strip-ansi": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.0.tgz", @@ -3431,15 +2846,6 @@ "node": ">=0.10.0" } }, - "node_modules/strip-final-newline": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", - "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", - "dev": true, - "engines": { - "node": ">=6" - } - }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -3539,12 +2945,6 @@ "node": ">=0.8" } }, - "node_modules/through": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", - "integrity": "sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=", - "dev": true - }, "node_modules/to-buffer": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.1.1.tgz", @@ -3615,9 +3015,9 @@ } }, "node_modules/typescript": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.1.3.tgz", - "integrity": "sha512-B3ZIOf1IKeH2ixgHhj6la6xdwR9QrLC5d1VKeCSY4tvkqhF2eqd9O7txNlS0PO3GrBAFIdr3L1ndNwteUbZLYg==", + "version": "4.9.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", + "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", "dev": true, "bin": { "tsc": "bin/tsc", @@ -3708,12 +3108,6 @@ "integrity": "sha512-B+enWhmw6cjfVC7kS8Pj9pCrKSc5txArRyaYGe088shv/FGWH+0Rjx/xPgtsWfsUtS27FkP697E4DDhgrgoc0Q==", "dev": true }, - "node_modules/which-pm-runs": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/which-pm-runs/-/which-pm-runs-1.0.0.tgz", - "integrity": "sha1-Zws6+8VS4LVd9rd4DKdGFfI60cs=", - "dev": true - }, "node_modules/word-wrap": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz", @@ -3723,20 +3117,6 @@ "node": ">=0.10.0" } }, - "node_modules/wrap-ansi": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", - "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -3764,15 +3144,6 @@ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "dev": true }, - "node_modules/yaml": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.0.tgz", - "integrity": "sha512-yr2icI4glYaNG+KWONODapy2/jDdMSDnrONSjblABjD9B4Z5LgiircSt8m8sRZFNi08kG9Sm0uSHtEmP3zaEGg==", - "dev": true, - "engines": { - "node": ">= 6" - } - }, "node_modules/yargs": { "version": "11.1.1", "resolved": "https://registry.npmjs.org/yargs/-/yargs-11.1.1.tgz", @@ -3903,15 +3274,6 @@ "node": ">=4" } }, - "node_modules/yocto-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "dev": true, - "engines": { - "node": ">=10" - } - }, "node_modules/zip-stream": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-1.2.0.tgz", @@ -4118,12 +3480,6 @@ "integrity": "sha512-cHYfKsnwllYhjOzuC5q1VpguABBeecUp24yFluHpn/BQaVxB1CuQ1FSRZCzrPxrkIfWISXV2LbeoBthLWg0+0A==", "dev": true }, - "@types/parse-json": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz", - "integrity": "sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==", - "dev": true - }, "@types/ramda": { "version": "0.27.34", "resolved": "https://registry.npmjs.org/@types/ramda/-/ramda-0.27.34.tgz", @@ -4243,16 +3599,6 @@ "integrity": "sha512-K0Ptm/47OKfQRpNQ2J/oIN/3QYiK6FwW+eJbILhsdxh2WTLdl+30o8aGdTbm5JbffpFFAg/g+zi1E+jvJha5ng==", "dev": true }, - "aggregate-error": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", - "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", - "dev": true, - "requires": { - "clean-stack": "^2.0.0", - "indent-string": "^4.0.0" - } - }, "ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -4271,23 +3617,6 @@ "integrity": "sha512-JoX0apGbHaUJBNl6yF+p6JAFYZ666/hhCGKN5t9QFjbJQKUU/g8MNbFDbvfrgKXvI1QpZplPOnwIo99lX/AAmA==", "dev": true }, - "ansi-escapes": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.1.tgz", - "integrity": "sha512-JWF7ocqNrp8u9oqpgV+wH5ftbt+cfvv+PTjOvKLT3AdYly/LmORARfEVT1iyjwN+4MqE5UmVKoAdIBqeoCHgLA==", - "dev": true, - "requires": { - "type-fest": "^0.11.0" - }, - "dependencies": { - "type-fest": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.11.0.tgz", - "integrity": "sha512-OdjXJxnCN1AvyLSzeKIgXTXxV+99ZuXl3Hpo9XpJAv9MBcHrrJOQ5kV7ypXOuQie+AmWG25hLbiKdwYTifzcfQ==", - "dev": true - } - } - }, "ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -4526,50 +3855,6 @@ "supports-color": "^7.1.0" } }, - "ci-info": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-2.0.0.tgz", - "integrity": "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==", - "dev": true - }, - "clean-stack": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", - "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", - "dev": true - }, - "cli-cursor": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", - "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", - "dev": true, - "requires": { - "restore-cursor": "^3.1.0" - } - }, - "cli-truncate": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-2.1.0.tgz", - "integrity": "sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==", - "dev": true, - "requires": { - "slice-ansi": "^3.0.0", - "string-width": "^4.2.0" - }, - "dependencies": { - "slice-ansi": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-3.0.0.tgz", - "integrity": "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ==", - "dev": true, - "requires": { - "ansi-styles": "^4.0.0", - "astral-regex": "^2.0.0", - "is-fullwidth-code-point": "^3.0.0" - } - } - } - }, "cliui": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/cliui/-/cliui-4.1.0.tgz", @@ -4682,18 +3967,6 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true }, - "commander": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.1.tgz", - "integrity": "sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==", - "dev": true - }, - "compare-versions": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/compare-versions/-/compare-versions-3.6.0.tgz", - "integrity": "sha512-W6Af2Iw1z4CB7q4uU4hv646dW9GQuBM+YpC0UvUCWSD8w90SJjp+ujJuXaEMtAXBtSqGfMPuFOVn4/+FlaqfBA==", - "dev": true - }, "compress-commons": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-1.2.2.tgz", @@ -4729,19 +4002,6 @@ "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", "dev": true }, - "cosmiconfig": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.0.0.tgz", - "integrity": "sha512-pondGvTuVYDk++upghXJabWzL6Kxu6f26ljFw64Swq9v6sQPUL3EUlVDV56diOjpCayKihL6hVe8exIACU4XcA==", - "dev": true, - "requires": { - "@types/parse-json": "^4.0.0", - "import-fresh": "^3.2.1", - "parse-json": "^5.0.0", - "path-type": "^4.0.0", - "yaml": "^1.10.0" - } - }, "crc": { "version": "3.8.0", "resolved": "https://registry.npmjs.org/crc/-/crc-3.8.0.tgz", @@ -4787,12 +4047,6 @@ "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", "dev": true }, - "dedent": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/dedent/-/dedent-0.7.0.tgz", - "integrity": "sha1-JJXduvbrh0q7Dhvp3yLS5aVEMmw=", - "dev": true - }, "deep-is": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.3.tgz", @@ -4856,15 +4110,6 @@ "ansi-colors": "^4.1.1" } }, - "error-ex": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", - "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", - "dev": true, - "requires": { - "is-arrayish": "^0.2.1" - } - }, "es-abstract": { "version": "1.18.0-next.1", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.18.0-next.1.tgz", @@ -5104,23 +4349,6 @@ "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", "dev": true }, - "execa": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/execa/-/execa-4.1.0.tgz", - "integrity": "sha512-j5W0//W7f8UxAn8hXVnwG8tLwdiUy4FJLcSupCg6maBYZDpyBvTApK7KyuI4bKj8KOh1r2YH+6ucuYtJv1bTZA==", - "dev": true, - "requires": { - "cross-spawn": "^7.0.0", - "get-stream": "^5.0.0", - "human-signals": "^1.1.1", - "is-stream": "^2.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^4.0.0", - "onetime": "^5.1.0", - "signal-exit": "^3.0.2", - "strip-final-newline": "^2.0.0" - } - }, "fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -5162,15 +4390,6 @@ "reusify": "^1.0.4" } }, - "figures": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", - "integrity": "sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==", - "dev": true, - "requires": { - "escape-string-regexp": "^1.0.5" - } - }, "file-entry-cache": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.0.tgz", @@ -5189,25 +4408,6 @@ "to-regex-range": "^5.0.1" } }, - "find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "dev": true, - "requires": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" - } - }, - "find-versions": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/find-versions/-/find-versions-4.0.0.tgz", - "integrity": "sha512-wgpWy002tA+wgmO27buH/9KzyEOQnKsG/R0yrcjPT9BOFm0zRBVQbZ95nRGXWMywS8YR5knRbpohio0bcJABxQ==", - "dev": true, - "requires": { - "semver-regex": "^3.1.2" - } - }, "flat-cache": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.0.4.tgz", @@ -5276,21 +4476,6 @@ "has-symbols": "^1.0.1" } }, - "get-own-enumerable-property-symbols": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/get-own-enumerable-property-symbols/-/get-own-enumerable-property-symbols-3.0.2.tgz", - "integrity": "sha512-I0UBV/XOz1XkIJHEUDMZAbzCThU/H8DxmSfmdGcKPnVhu2VfFqr34jr9777IyaTYvxjedWhqVIilEDsCdP5G6g==", - "dev": true - }, - "get-stream": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", - "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", - "dev": true, - "requires": { - "pump": "^3.0.0" - } - }, "glob": { "version": "7.1.6", "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz", @@ -5364,30 +4549,6 @@ "integrity": "sha512-PLcsoqu++dmEIZB+6totNFKq/7Do+Z0u4oT0zKOJNl3lYK6vGwwu2hjHs+68OEZbTjiUE9bgOABXbP/GvrS0Kg==", "dev": true }, - "human-signals": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-1.1.1.tgz", - "integrity": "sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw==", - "dev": true - }, - "husky": { - "version": "4.3.8", - "resolved": "https://registry.npmjs.org/husky/-/husky-4.3.8.tgz", - "integrity": "sha512-LCqqsB0PzJQ/AlCgfrfzRe3e3+NvmefAdKQhRYpxS4u6clblBoDdzzvHi8fmxKRzvMxPY/1WZWzomPZww0Anow==", - "dev": true, - "requires": { - "chalk": "^4.0.0", - "ci-info": "^2.0.0", - "compare-versions": "^3.6.0", - "cosmiconfig": "^7.0.0", - "find-versions": "^4.0.0", - "opencollective-postinstall": "^2.0.2", - "pkg-dir": "^5.0.0", - "please-upgrade-node": "^3.2.0", - "slash": "^3.0.0", - "which-pm-runs": "^1.0.0" - } - }, "ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -5425,12 +4586,6 @@ "integrity": "sha1-khi5srkoojixPcT7a21XbyMUU+o=", "dev": true }, - "indent-string": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", - "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", - "dev": true - }, "inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", @@ -5485,12 +4640,6 @@ "integrity": "sha512-wPVv/y/QQ/Uiirj/vh3oP+1Ww+AWehmi1g5fFWGPF6IpCBCDVrhgHRMvrLfdYcwDh3QJbGXDW4JAuzxElLSqKA==", "dev": true }, - "is-arrayish": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", - "integrity": "sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0=", - "dev": true - }, "is-callable": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.2.tgz", @@ -5545,12 +4694,6 @@ "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", "dev": true }, - "is-obj": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-1.0.1.tgz", - "integrity": "sha1-PkcprB9f3gJc19g6iW2rn09n2w8=", - "dev": true - }, "is-regex": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.1.tgz", @@ -5560,18 +4703,6 @@ "has-symbols": "^1.0.1" } }, - "is-regexp": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-regexp/-/is-regexp-1.0.0.tgz", - "integrity": "sha1-/S2INUXEa6xaYz57mgnof6LLUGk=", - "dev": true - }, - "is-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.0.tgz", - "integrity": "sha512-XCoy+WlUr7d1+Z8GgSuXmpuUFC9fOhRXglJMx+dwLKTkL44Cjd4W1Z5P+BQZpr+cR93aGP4S/s7Ftw6Nd/kiEw==", - "dev": true - }, "is-string": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.5.tgz", @@ -5633,12 +4764,6 @@ "esprima": "^4.0.0" } }, - "json-parse-even-better-errors": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", - "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "dev": true - }, "json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -5698,87 +4823,12 @@ "type-check": "~0.4.0" } }, - "lines-and-columns": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.1.6.tgz", - "integrity": "sha1-HADHQ7QzzQpOgHWPe2SldEDZ/wA=", - "dev": true - }, - "lint-staged": { - "version": "10.5.3", - "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-10.5.3.tgz", - "integrity": "sha512-TanwFfuqUBLufxCc3RUtFEkFraSPNR3WzWcGF39R3f2J7S9+iF9W0KTVLfSy09lYGmZS5NDCxjNvhGMSJyFCWg==", - "dev": true, - "requires": { - "chalk": "^4.1.0", - "cli-truncate": "^2.1.0", - "commander": "^6.2.0", - "cosmiconfig": "^7.0.0", - "debug": "^4.2.0", - "dedent": "^0.7.0", - "enquirer": "^2.3.6", - "execa": "^4.1.0", - "listr2": "^3.2.2", - "log-symbols": "^4.0.0", - "micromatch": "^4.0.2", - "normalize-path": "^3.0.0", - "please-upgrade-node": "^3.2.0", - "string-argv": "0.3.1", - "stringify-object": "^3.3.0" - } - }, - "listr2": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/listr2/-/listr2-3.2.3.tgz", - "integrity": "sha512-vUb80S2dSUi8YxXahO8/I/s29GqnOL8ozgHVLjfWQXa03BNEeS1TpBLjh2ruaqq5ufx46BRGvfymdBSuoXET5w==", - "dev": true, - "requires": { - "chalk": "^4.1.0", - "cli-truncate": "^2.1.0", - "figures": "^3.2.0", - "indent-string": "^4.0.0", - "log-update": "^4.0.0", - "p-map": "^4.0.0", - "rxjs": "^6.6.3", - "through": "^2.3.8" - } - }, - "locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "dev": true, - "requires": { - "p-locate": "^5.0.0" - } - }, "lodash": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "dev": true }, - "log-symbols": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.0.0.tgz", - "integrity": "sha512-FN8JBzLx6CzeMrB0tg6pqlGU1wCrXW+ZXGH481kfsBqer0hToTIiHdjH4Mq8xJUbvATujKCvaREGWpGUionraA==", - "dev": true, - "requires": { - "chalk": "^4.0.0" - } - }, - "log-update": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/log-update/-/log-update-4.0.0.tgz", - "integrity": "sha512-9fkkDevMefjg0mmzWFBW8YkFP91OrizzkW3diF7CpG+S2EYdy4+TVfGwz1zeF8x7hCx1ovSPTOE9Ngib74qqUg==", - "dev": true, - "requires": { - "ansi-escapes": "^4.3.0", - "cli-cursor": "^3.1.0", - "slice-ansi": "^4.0.0", - "wrap-ansi": "^6.2.0" - } - }, "loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -5817,12 +4867,6 @@ "p-is-promise": "^2.0.0" } }, - "merge-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", - "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", - "dev": true - }, "merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -5888,12 +4932,6 @@ "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==", "dev": true }, - "normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true - }, "npm-bundled": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/npm-bundled/-/npm-bundled-1.1.2.tgz", @@ -5934,15 +4972,6 @@ "npm-normalize-package-bin": "^1.0.1" } }, - "npm-run-path": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", - "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", - "dev": true, - "requires": { - "path-key": "^3.0.0" - } - }, "number-is-nan": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz", @@ -6024,21 +5053,6 @@ "wrappy": "1" } }, - "onetime": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", - "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", - "dev": true, - "requires": { - "mimic-fn": "^2.1.0" - } - }, - "opencollective-postinstall": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/opencollective-postinstall/-/opencollective-postinstall-2.0.3.tgz", - "integrity": "sha512-8AV/sCtuzUeTo8gQK5qDZzARrulB3egtLzFgteqB2tcT4Mw7B8Kt7JcDHmltjz6FOAHsvTevk70gZEbhM4ZS9Q==", - "dev": true - }, "optionator": { "version": "0.9.1", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz", @@ -6172,33 +5186,6 @@ "integrity": "sha512-Y3W0wlRPK8ZMRbNq97l4M5otioeA5lm1z7bkNkxCka8HSPjR0xRWmpCmc9utiaLP9Jb1eD8BgeIxTW4AIF45Pg==", "dev": true }, - "p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, - "requires": { - "yocto-queue": "^0.1.0" - } - }, - "p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "dev": true, - "requires": { - "p-limit": "^3.0.2" - } - }, - "p-map": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", - "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==", - "dev": true, - "requires": { - "aggregate-error": "^3.0.0" - } - }, "p-try": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/p-try/-/p-try-1.0.0.tgz", @@ -6214,24 +5201,6 @@ "callsites": "^3.0.0" } }, - "parse-json": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.1.0.tgz", - "integrity": "sha512-+mi/lmVVNKFNVyLXV31ERiy2CY5E1/F6QtJFEzoChPRwwngMNXRDQ9GJ5WdE2Z2P4AujsOi0/+2qHID68KwfIQ==", - "dev": true, - "requires": { - "@babel/code-frame": "^7.0.0", - "error-ex": "^1.3.1", - "json-parse-even-better-errors": "^2.3.0", - "lines-and-columns": "^1.1.6" - } - }, - "path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true - }, "path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -6262,24 +5231,6 @@ "integrity": "sha512-q0M/9eZHzmr0AulXyPwNfZjtwZ/RBZlbN3K3CErVrk50T2ASYI7Bye0EvekFY3IP1Nt2DHu0re+V2ZHIpMkuWg==", "dev": true }, - "pkg-dir": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-5.0.0.tgz", - "integrity": "sha512-NPE8TDbzl/3YQYY7CSS228s3g2ollTFnc+Qi3tqmqJp9Vg2ovUpixcJEo2HJScN2Ez+kEaal6y70c0ehqJBJeA==", - "dev": true, - "requires": { - "find-up": "^5.0.0" - } - }, - "please-upgrade-node": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/please-upgrade-node/-/please-upgrade-node-3.2.0.tgz", - "integrity": "sha512-gQR3WpIgNIKwBMVLkpMUeR3e1/E1y42bqDQZfql+kDeXd8COYfM8PQA4X6y7a8u9Ua9FHmsrrmirW2vHs45hWg==", - "dev": true, - "requires": { - "semver-compare": "^1.0.0" - } - }, "prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -6445,16 +5396,6 @@ "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", "dev": true }, - "restore-cursor": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", - "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", - "dev": true, - "requires": { - "onetime": "^5.1.0", - "signal-exit": "^3.0.2" - } - }, "reusify": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", @@ -6476,15 +5417,6 @@ "integrity": "sha512-zb/1OuZ6flOlH6tQyMPUrE3x3Ulxjlo9WIVXR4yVYi4H9UXQaeIsPbLn2R3O3vQCnDKkAl2qHiuocKKX4Tz/Sw==", "dev": true }, - "rxjs": { - "version": "6.6.3", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.6.3.tgz", - "integrity": "sha512-trsQc+xYYXZ3urjOiJOuCOa5N3jAZ3eiSpQB5hIT8zGlL2QfnHLJ2r7GMkBGuIausdJN1OneaI6gQlsqNHHmZQ==", - "dev": true, - "requires": { - "tslib": "^1.9.0" - } - }, "safe-buffer": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", @@ -6509,18 +5441,6 @@ "lru-cache": "^6.0.0" } }, - "semver-compare": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz", - "integrity": "sha1-De4hahyUGrN+nvsXiPavxf9VN/w=", - "dev": true - }, - "semver-regex": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/semver-regex/-/semver-regex-3.1.3.tgz", - "integrity": "sha512-Aqi54Mk9uYTjVexLnR67rTyBusmwd04cLkHy9hNvk3+G3nT2Oyg7E0l4XVbOaNwIvQ3hHeYxGcyEy+mKreyBFQ==", - "dev": true - }, "set-blocking": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", @@ -6591,12 +5511,6 @@ "safe-buffer": "~5.1.0" } }, - "string-argv": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.1.tgz", - "integrity": "sha512-a1uQGz7IyVy9YwhqjZIZu1c8JO8dNIe20xBmSS6qu9kv++k3JGzCVmprbNN5Kn+BgzD5E7YYwg1CcjuJMRNsvg==", - "dev": true - }, "string-width": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.0.tgz", @@ -6643,17 +5557,6 @@ "define-properties": "^1.1.3" } }, - "stringify-object": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/stringify-object/-/stringify-object-3.3.0.tgz", - "integrity": "sha512-rHqiFh1elqCQ9WPLIC8I0Q/g/wj5J1eMkyoiD6eoQApWHP0FtlK7rqnhmabL5VUY9JQCcqwwvlOaSuutekgyrw==", - "dev": true, - "requires": { - "get-own-enumerable-property-symbols": "^3.0.0", - "is-obj": "^1.0.1", - "is-regexp": "^1.0.0" - } - }, "strip-ansi": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.0.tgz", @@ -6669,12 +5572,6 @@ "integrity": "sha512-7FCwGGmx8mD5xQd3RPUvnSpUXHM3BWuzjtpD4TXsfcZ9EL4azvVVUscFYwD9nx8Kh+uCBC00XBtAykoMHwTh8Q==", "dev": true }, - "strip-final-newline": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", - "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", - "dev": true - }, "strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -6761,12 +5658,6 @@ "thenify": ">= 3.1.0 < 4" } }, - "through": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", - "integrity": "sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=", - "dev": true - }, "to-buffer": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.1.1.tgz", @@ -6825,9 +5716,9 @@ "dev": true }, "typescript": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.1.3.tgz", - "integrity": "sha512-B3ZIOf1IKeH2ixgHhj6la6xdwR9QrLC5d1VKeCSY4tvkqhF2eqd9O7txNlS0PO3GrBAFIdr3L1ndNwteUbZLYg==", + "version": "4.9.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", + "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", "dev": true }, "typescript-is": { @@ -6894,29 +5785,12 @@ "integrity": "sha512-B+enWhmw6cjfVC7kS8Pj9pCrKSc5txArRyaYGe088shv/FGWH+0Rjx/xPgtsWfsUtS27FkP697E4DDhgrgoc0Q==", "dev": true }, - "which-pm-runs": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/which-pm-runs/-/which-pm-runs-1.0.0.tgz", - "integrity": "sha1-Zws6+8VS4LVd9rd4DKdGFfI60cs=", - "dev": true - }, "word-wrap": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz", "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==", "dev": true }, - "wrap-ansi": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", - "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", - "dev": true, - "requires": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - } - }, "wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -6941,12 +5815,6 @@ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "dev": true }, - "yaml": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.0.tgz", - "integrity": "sha512-yr2icI4glYaNG+KWONODapy2/jDdMSDnrONSjblABjD9B4Z5LgiircSt8m8sRZFNi08kG9Sm0uSHtEmP3zaEGg==", - "dev": true - }, "yargs": { "version": "11.1.1", "resolved": "https://registry.npmjs.org/yargs/-/yargs-11.1.1.tgz", @@ -7052,12 +5920,6 @@ "camelcase": "^4.1.0" } }, - "yocto-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "dev": true - }, "zip-stream": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-1.2.0.tgz", diff --git a/package.json b/package.json index 77796c1e5..a773dc1db 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,7 @@ "npm-pack-zip": "^1.3.0", "prettier": "^2.1.2", "prettier-plugin-organize-imports": "^1.1.1", - "typescript": "^4.1.2" + "typescript": "^4.9.5" }, "engines": { "node": "18.x.x", @@ -43,13 +43,10 @@ "typescript-is": "^0.17.0" }, "files": [ - "townService/src/town/Town.ts", - "townService/src/town/TownsController.ts", - "frontend/src/classes/TownController.ts", - "frontend/src/classes/ConversationAreaController.ts", - "frontend/src/classes/ConversationAreaController.test.ts", - "frontend/src/classes/ViewingAreaController.ts", - "frontend/src/classes/ViewingAreaController.test.ts", - "frontend/src/components/Town/interactables/ViewingAreaVideo.tsx" - ] + "frontend/src/classes/interactable/TicTacToeAreaController.ts", + "frontend/src/classes/interactable/TicTacToeAreaController.test.ts", + "frontend/src/components/Town/interactables/TicTacToe/TicTacToeArea.tsx", + "frontend/src/components/Town/interactables/TicTacToe/TicTacToeBoard.tsx", + "frontend/src/components/Town/interactables/Leaderboard.tsx" + ] } diff --git a/shared/types/CoveyTownSocket.d.ts b/shared/types/CoveyTownSocket.d.ts index 6ae74bc80..0ce9eefb4 100644 --- a/shared/types/CoveyTownSocket.d.ts +++ b/shared/types/CoveyTownSocket.d.ts @@ -14,10 +14,15 @@ export type TownJoinResponse = { /** Is this a private town? * */ isPubliclyListed: boolean; /** Current state of interactables in this town */ - interactables: Interactable[]; + interactables: TypedInteractable[]; } -export type Interactable = ViewingArea | ConversationArea; +export type InteractableType = 'ConversationArea' | 'ViewingArea' | 'TicTacToeArea'; +export interface Interactable { + type: InteractableType; + id: InteractableID; + occupants: PlayerID[]; +} export type TownSettingsUpdate = { friendlyName?: string; @@ -25,8 +30,10 @@ export type TownSettingsUpdate = { } export type Direction = 'front' | 'back' | 'left' | 'right'; + +export type PlayerID = string; export interface Player { - id: string; + id: PlayerID; userName: string; location: PlayerLocation; }; @@ -50,10 +57,8 @@ export type ChatMessage = { dateCreated: Date; }; -export interface ConversationArea { - id: string; +export interface ConversationArea extends Interactable { topic?: string; - occupantsByID: string[]; }; export interface BoundingBox { x: number; @@ -62,13 +67,144 @@ export interface BoundingBox { height: number; }; -export interface ViewingArea { - id: string; +export interface ViewingArea extends Interactable { video?: string; isPlaying: boolean; elapsedTimeSec: number; } +export type GameStatus = 'IN_PROGRESS' | 'WAITING_TO_START' | 'OVER'; +/** + * Base type for the state of a game + */ +export interface GameState { + status: GameStatus; +} + +/** + * Type for the state of a game that can be won + */ +export interface WinnableGameState extends GameState { + winner?: PlayerID; +} +/** + * Base type for a move in a game. Implementers should also extend MoveType + * @see MoveType + */ +export interface GameMove { + playerID: PlayerID; + gameID: GameInstanceID; + move: MoveType; +} + +export type TicTacToeGridPosition = 0 | 1 | 2; + +/** + * Type for a move in TicTacToe + */ +export interface TicTacToeMove { + gamePiece: 'X' | 'O'; + row: TicTacToeGridPosition; + col: TicTacToeGridPosition; +} + +/** + * Type for the state of a TicTacToe game + * The state of the game is represented as a list of moves, and the playerIDs of the players (x and o) + * The first player to join the game is x, the second is o + */ +export interface TicTacToeGameState extends WinnableGameState { + moves: ReadonlyArray; + x?: PlayerID; + o?: PlayerID; +} + +export type InteractableID = string; +export type GameInstanceID = string; + +/** + * Type for the result of a game + */ +export interface GameResult { + gameID: GameInstanceID; + scores: { [playerName: string]: number }; +} + +/** + * Base type for an *instance* of a game. An instance of a game + * consists of the present state of the game (which can change over time), + * the players in the game, and the result of the game + * @see GameState + */ +export interface GameInstance { + state: T; + id: GameInstanceID; + players: PlayerID[]; + result?: GameResult; +} + +/** + * Base type for an area that can host a game + * @see GameInstance + */ +export interface GameArea extends Interactable { + game: GameInstance | undefined; + history: GameResult[]; +} + +export type CommandID = string; + +/** + * Base type for a command that can be sent to an interactable. + * This type is used only by the client/server interface, which decorates + * an @see InteractableCommand with a commandID and interactableID + */ +interface InteractableCommandBase { + /** + * A unique ID for this command. This ID is used to match a command against a response + */ + commandID: CommandID; + /** + * The ID of the interactable that this command is being sent to + */ + interactableID: InteractableID; + /** + * The type of this command + */ + type: string; +} + +export type InteractableCommand = ViewingAreaUpdateCommand | JoinGameCommand | GameMoveCommand | LeaveGameCommand; +export interface ViewingAreaUpdateCommand { + type: 'ViewingAreaUpdate'; + update: ViewingArea; +} +export interface JoinGameCommand { + type: 'JoinGame'; +} +export interface LeaveGameCommand { + type: 'LeaveGame'; + gameID: GameInstanceID; +} +export interface GameMoveCommand { + type: 'GameMove'; + gameID: GameInstanceID; + move: MoveType; +} +export type InteractableCommandReturnType = + CommandType extends JoinGameCommand ? { gameID: string}: + CommandType extends ViewingAreaUpdateCommand ? undefined : + CommandType extends GameMoveCommand ? undefined : + CommandType extends LeaveGameCommand ? undefined : + never; + +export type InteractableCommandResponse = { + commandID: CommandID; + interactableID: InteractableID; + error?: string; + payload?: InteractableCommandResponseMap[MessageType]; +} + export interface ServerToClientEvents { playerMoved: (movedPlayer: Player) => void; playerDisconnect: (disconnectedPlayer: Player) => void; @@ -78,10 +214,12 @@ export interface ServerToClientEvents { townClosing: () => void; chatMessage: (message: ChatMessage) => void; interactableUpdate: (interactable: Interactable) => void; + commandResponse: (response: InteractableCommandResponse) => void; } export interface ClientToServerEvents { chatMessage: (message: ChatMessage) => void; playerMovement: (movementData: PlayerLocation) => void; interactableUpdate: (update: Interactable) => void; + interactableCommand: (command: InteractableCommand & InteractableCommandBase) => void; } \ No newline at end of file diff --git a/townService/.eslintrc.cjs b/townService/.eslintrc.cjs index 25f5a2a9c..558ccf282 100644 --- a/townService/.eslintrc.cjs +++ b/townService/.eslintrc.cjs @@ -20,6 +20,8 @@ module.exports = { 'no-param-reassign': 0, 'no-restricted-syntax': 0, 'no-plusplus': 0, + 'class-methods-use-this': 0, + '@typescript-eslint/no-unused-vars': [1, { args: 'none' }], 'import/no-extraneous-dependencies': [ 'error', { devDependencies: ['**/*.test.ts', '**/TestUtils.ts'] }, diff --git a/townService/package-lock.json b/townService/package-lock.json index b87a58528..bf390973c 100644 --- a/townService/package-lock.json +++ b/townService/package-lock.json @@ -51,6 +51,10 @@ "ts-jest": "^28.0.5", "ts-node": "^10.8.2", "typescript": "^4.7.3" + }, + "engines": { + "node": "18.x.x", + "npm": "9.x.x" } }, "node_modules/@ampproject/remapping": { diff --git a/townService/package.json b/townService/package.json index 1670e016c..4210c2999 100644 --- a/townService/package.json +++ b/townService/package.json @@ -13,6 +13,10 @@ "bugs": { "url": "https://github.com/neu-se/covey.town/issues" }, + "engines": { + "node": "18.x.x", + "npm": "9.x.x" + }, "homepage": "https://github.com/neu-se/covey.town#readme", "scripts": { "test": "jest ", @@ -71,4 +75,4 @@ "tsoa": "^4.1.0", "twilio": "^3.78.0" } -} +} \ No newline at end of file diff --git a/townService/src/TestUtils.ts b/townService/src/TestUtils.ts index dafe651cf..9850787f4 100644 --- a/townService/src/TestUtils.ts +++ b/townService/src/TestUtils.ts @@ -21,6 +21,7 @@ import { PlayerLocation, ServerToClientEvents, SocketData, + TownEmitter, ViewingArea, } from './types/CoveyTownSocket'; @@ -36,8 +37,9 @@ export function createConversationForTesting(params?: { }): ConversationArea { return { id: params?.conversationID || nanoid(), - occupantsByID: [], + occupants: [], topic: params?.conversationTopic || nanoid(), + type: 'ConversationArea', }; } @@ -183,6 +185,14 @@ export function mockPlayer(townID: string): MockedPlayer { return new MockedPlayer(socket, socketToRoomMock, userName, townID, undefined); } +/** + * Utility function to create a new player object for testing, not connected to any town + * + */ +export function createPlayerForTesting(): Player { + return new Player(`username${nanoid()}`, mock()); +} + /** * Assert that two arrays contain the same members (by strict === equality), allowing them to appear in different orders * @param actual diff --git a/townService/src/lib/InvalidParametersError.ts b/townService/src/lib/InvalidParametersError.ts index f62165387..d1ec6ef59 100644 --- a/townService/src/lib/InvalidParametersError.ts +++ b/townService/src/lib/InvalidParametersError.ts @@ -1,3 +1,16 @@ +export const INVALID_MOVE_MESSAGE = 'Invalid move'; +export const INVALID_COMMAND_MESSAGE = 'Invalid command'; + +export const GAME_FULL_MESSAGE = 'Game is full'; +export const GAME_NOT_IN_PROGRESS_MESSAGE = 'Game is not in progress'; +export const GAME_OVER_MESSAGE = 'Game is over'; +export const GAME_ID_MISSMATCH_MESSAGE = 'Game ID mismatch'; + +export const BOARD_POSITION_NOT_EMPTY_MESSAGE = 'Board position is not empty'; +export const MOVE_NOT_YOUR_TURN_MESSAGE = 'Not your turn'; + +export const PLAYER_NOT_IN_GAME_MESSAGE = 'Player is not in this game'; +export const PLAYER_ALREADY_IN_GAME_MESSAGE = 'Player is already in this game'; export default class InvalidParametersError extends Error { public message: string; diff --git a/townService/src/town/ConversationArea.test.ts b/townService/src/town/ConversationArea.test.ts index a0f64122d..8a23cff10 100644 --- a/townService/src/town/ConversationArea.test.ts +++ b/townService/src/town/ConversationArea.test.ts @@ -15,7 +15,7 @@ describe('ConversationArea', () => { beforeEach(() => { mockClear(townEmitter); - testArea = new ConversationArea({ topic, id, occupantsByID: [] }, testAreaBox, townEmitter); + testArea = new ConversationArea({ topic, id, occupants: [] }, testAreaBox, townEmitter); newPlayer = new Player(nanoid(), mock()); testArea.add(newPlayer); }); @@ -24,7 +24,12 @@ describe('ConversationArea', () => { expect(testArea.occupantsByID).toEqual([newPlayer.id]); const lastEmittedUpdate = getLastEmittedEvent(townEmitter, 'interactableUpdate'); - expect(lastEmittedUpdate).toEqual({ topic, id, occupantsByID: [newPlayer.id] }); + expect(lastEmittedUpdate).toEqual({ + topic, + id, + occupants: [newPlayer.id], + type: 'ConversationArea', + }); }); it("Sets the player's conversationLabel and emits an update for their location", () => { expect(newPlayer.location.interactableID).toEqual(id); @@ -42,7 +47,12 @@ describe('ConversationArea', () => { expect(testArea.occupantsByID).toEqual([extraPlayer.id]); const lastEmittedUpdate = getLastEmittedEvent(townEmitter, 'interactableUpdate'); - expect(lastEmittedUpdate).toEqual({ topic, id, occupantsByID: [extraPlayer.id] }); + expect(lastEmittedUpdate).toEqual({ + topic, + id, + occupants: [extraPlayer.id], + type: 'ConversationArea', + }); }); it("Clears the player's conversationLabel and emits an update for their location", () => { testArea.remove(newPlayer); @@ -53,16 +63,22 @@ describe('ConversationArea', () => { it('Clears the topic of the conversation area when the last occupant leaves', () => { testArea.remove(newPlayer); const lastEmittedUpdate = getLastEmittedEvent(townEmitter, 'interactableUpdate'); - expect(lastEmittedUpdate).toEqual({ topic: undefined, id, occupantsByID: [] }); + expect(lastEmittedUpdate).toEqual({ + topic: undefined, + id, + occupants: [], + type: 'ConversationArea', + }); expect(testArea.topic).toBeUndefined(); }); }); - test('toModel sets the ID, topic and occupantsByID and sets no other properties', () => { + test('toModel sets the ID, topic and occupants and sets no other properties', () => { const model = testArea.toModel(); expect(model).toEqual({ id, topic, - occupantsByID: [newPlayer.id], + occupants: [newPlayer.id], + type: 'ConversationArea', }); }); describe('fromMapObject', () => { diff --git a/townService/src/town/ConversationArea.ts b/townService/src/town/ConversationArea.ts index 2e055ab31..d2db9e344 100644 --- a/townService/src/town/ConversationArea.ts +++ b/townService/src/town/ConversationArea.ts @@ -1,8 +1,11 @@ import { ITiledMapObject } from '@jonbell/tiled-map-type-guard'; +import InvalidParametersError from '../lib/InvalidParametersError'; import Player from '../lib/Player'; import { BoundingBox, ConversationArea as ConversationAreaModel, + InteractableCommand, + InteractableCommandReturnType, TownEmitter, } from '../types/CoveyTownSocket'; import InteractableArea from './InteractableArea'; @@ -24,7 +27,7 @@ export default class ConversationArea extends InteractableArea { * @param townEmitter a broadcast emitter that can be used to emit updates to players */ public constructor( - { topic, id }: ConversationAreaModel, + { topic, id }: Omit, coordinates: BoundingBox, townEmitter: TownEmitter, ) { @@ -55,8 +58,9 @@ export default class ConversationArea extends InteractableArea { public toModel(): ConversationAreaModel { return { id: this.id, - occupantsByID: this.occupantsByID, + occupants: this.occupantsByID, topic: this.topic, + type: 'ConversationArea', }; } @@ -75,6 +79,12 @@ export default class ConversationArea extends InteractableArea { throw new Error(`Malformed viewing area ${name}`); } const rect: BoundingBox = { x: mapObject.x, y: mapObject.y, width, height }; - return new ConversationArea({ id: name, occupantsByID: [] }, rect, broadcastEmitter); + return new ConversationArea({ id: name, occupants: [] }, rect, broadcastEmitter); + } + + public handleCommand< + CommandType extends InteractableCommand, + >(): InteractableCommandReturnType { + throw new InvalidParametersError('Unknown command type'); } } diff --git a/townService/src/town/InteractableArea.test.ts b/townService/src/town/InteractableArea.test.ts index 4a0870bbe..fd667f6e6 100644 --- a/townService/src/town/InteractableArea.test.ts +++ b/townService/src/town/InteractableArea.test.ts @@ -2,13 +2,26 @@ import { mock, mockClear } from 'jest-mock-extended'; import { nanoid } from 'nanoid'; import Player from '../lib/Player'; import { defaultLocation, getLastEmittedEvent } from '../TestUtils'; -import { BoundingBox, Interactable, TownEmitter, XY } from '../types/CoveyTownSocket'; +import { + BoundingBox, + ConversationArea as ConversationAreaModel, + InteractableCommand, + InteractableCommandReturnType, + TownEmitter, + XY, +} from '../types/CoveyTownSocket'; import ConversationArea from './ConversationArea'; import InteractableArea, { PLAYER_SPRITE_HEIGHT, PLAYER_SPRITE_WIDTH } from './InteractableArea'; class TestInteractableArea extends InteractableArea { - public toModel(): Interactable { - return { id: this.id, occupantsByID: [] }; + public handleCommand< + CommandType extends InteractableCommand, + >(): InteractableCommandReturnType { + throw new Error('Method not implemented.'); + } + + public toModel(): ConversationAreaModel { + return { id: this.id, occupants: [], type: 'ConversationArea' }; } } const HALF_W = PLAYER_SPRITE_WIDTH / 2; @@ -162,7 +175,7 @@ describe('InteractableArea', () => { expect( testArea.overlaps( new ConversationArea( - { id: 'testArea', occupantsByID: [] }, + { id: 'testArea', occupants: [] }, intersectBox, mock(), ), @@ -204,7 +217,7 @@ describe('InteractableArea', () => { expect( testArea.overlaps( new ConversationArea( - { id: 'testArea', occupantsByID: [] }, + { id: 'testArea', occupants: [] }, intersectBox, mock(), ), @@ -245,7 +258,7 @@ describe('InteractableArea', () => { expect( testArea.overlaps( new ConversationArea( - { id: 'testArea', occupantsByID: [] }, + { id: 'testArea', occupants: [] }, intersectBox, mock(), ), diff --git a/townService/src/town/InteractableArea.ts b/townService/src/town/InteractableArea.ts index f4453aa1c..5379ac40e 100644 --- a/townService/src/town/InteractableArea.ts +++ b/townService/src/town/InteractableArea.ts @@ -1,12 +1,20 @@ import Player from '../lib/Player'; -import { BoundingBox, Interactable, PlayerLocation, TownEmitter } from '../types/CoveyTownSocket'; +import { + BoundingBox, + Interactable, + InteractableCommand, + InteractableCommandReturnType, + InteractableID, + PlayerLocation, + TownEmitter, +} from '../types/CoveyTownSocket'; export const PLAYER_SPRITE_WIDTH = 32; export const PLAYER_SPRITE_HEIGHT = 64; export default abstract class InteractableArea { /* The unique ID of this area */ - private readonly _id: string; + private readonly _id: InteractableID; /* The x coordinate of the top left of this area */ private _x: number; @@ -30,12 +38,16 @@ export default abstract class InteractableArea { return this._id; } + public get occupants(): Player[] { + return this._occupants; + } + public get occupantsByID(): string[] { return this._occupants.map(eachPlayer => eachPlayer.id); } public get isActive(): boolean { - return this._occupants.length > 0; + return this.occupants.length > 0; } public get boundingBox(): BoundingBox { @@ -160,4 +172,9 @@ export default abstract class InteractableArea { * otherwise serialization errors will occur when attempting to transmit it */ public abstract toModel(): Interactable; + + public abstract handleCommand( + command: CommandType, + player: Player, + ): InteractableCommandReturnType; } diff --git a/townService/src/town/Town.test.ts b/townService/src/town/Town.test.ts index 4f996ad17..90d854e34 100644 --- a/townService/src/town/Town.test.ts +++ b/townService/src/town/Town.test.ts @@ -14,6 +14,7 @@ import { import { ChatMessage, Interactable, + PlayerID, PlayerLocation, TownEmitter, ViewingArea as ViewingAreaModel, @@ -349,12 +350,14 @@ describe('Town', () => { let town: Town; let player: Player; let playerTestData: MockedPlayer; + let playerID: PlayerID; beforeEach(async () => { town = new Town(nanoid(), false, nanoid(), townEmitter); playerTestData = mockPlayer(town.townID); player = await town.addPlayer(playerTestData.userName, playerTestData.socket); playerTestData.player = player; + playerID = player.id; // Set this dummy player to be off the map so that they do not show up in conversation areas playerTestData.moveTo(-1, -1); @@ -390,14 +393,18 @@ describe('Town', () => { ); }); describe('[T1] interactableUpdate callback', () => { - let interactableUpdateHandler: (update: Interactable) => void; + let interactableUpdateHandler: (update: ViewingAreaModel) => void; beforeEach(() => { town.initializeFromMap(testingMaps.twoConvTwoViewing); interactableUpdateHandler = getEventListener(playerTestData.socket, 'interactableUpdate'); }); it('Should not throw an error for any interactable area that is not a viewing area', () => { expect(() => - interactableUpdateHandler({ id: 'Name1', topic: nanoid(), occupantsByID: [] }), + interactableUpdateHandler({ + id: 'Name1', + topic: nanoid(), + occupantsByID: [], + } as unknown as ViewingAreaModel), ).not.toThrowError(); }); it('Should not throw an error if there is no such viewing area', () => { @@ -406,7 +413,7 @@ describe('Town', () => { id: 'NotActuallyAnInteractable', topic: nanoid(), occupantsByID: [], - }), + } as unknown as ViewingAreaModel), ).not.toThrowError(); }); describe('When called passing a valid viewing area', () => { @@ -418,6 +425,8 @@ describe('Town', () => { elapsedTimeSec: 0, isPlaying: true, video: nanoid(), + occupants: [], + type: 'ViewingArea', }; expect(town.addViewingArea(newArea)).toBe(true); secondPlayer = mockPlayer(town.townID); @@ -485,7 +494,12 @@ describe('Town', () => { town.initializeFromMap(testingMaps.twoConvOneViewing); playerTestData.moveTo(45, 122); // Inside of "Name1" area expect( - town.addConversationArea({ id: 'Name1', topic: 'test', occupantsByID: [] }), + town.addConversationArea({ + id: 'Name1', + topic: 'test', + occupants: [], + type: 'ConversationArea', + }), ).toBeTruthy(); const convArea = town.getInteractable('Name1') as ConversationArea; expect(convArea.occupantsByID).toEqual([player.id]); @@ -499,7 +513,14 @@ describe('Town', () => { town.initializeFromMap(testingMaps.twoConvOneViewing); playerTestData.moveTo(156, 567); // Inside of "Name3" area expect( - town.addViewingArea({ id: 'Name3', isPlaying: true, elapsedTimeSec: 0, video: nanoid() }), + town.addViewingArea({ + id: 'Name3', + isPlaying: true, + elapsedTimeSec: 0, + video: nanoid(), + occupants: [], + type: 'ViewingArea', + }), ).toBeTruthy(); const viewingArea = town.getInteractable('Name3'); expect(viewingArea.occupantsByID).toEqual([player.id]); @@ -545,6 +566,8 @@ describe('Town', () => { isPlaying: true, elapsedTimeSec: 100, video: nanoid(), + occupants: [], + type: 'ViewingArea', }; interactableUpdateCallback(update); }); @@ -587,23 +610,48 @@ describe('Town', () => { }); it('Should return false if no area exists with that ID', () => { expect( - town.addConversationArea({ id: nanoid(), topic: nanoid(), occupantsByID: [] }), + town.addConversationArea({ + id: nanoid(), + topic: nanoid(), + occupants: [], + type: 'ConversationArea', + }), ).toEqual(false); }); it('Should return false if the requested topic is empty', () => { - expect(town.addConversationArea({ id: 'Name1', topic: '', occupantsByID: [] })).toEqual( - false, - ); expect( - town.addConversationArea({ id: 'Name1', topic: undefined, occupantsByID: [] }), + town.addConversationArea({ + id: 'Name1', + topic: '', + occupants: [], + type: 'ConversationArea', + }), + ).toEqual(false); + expect( + town.addConversationArea({ + id: 'Name1', + topic: undefined, + occupants: [], + type: 'ConversationArea', + }), ).toEqual(false); }); it('Should return false if the area already has a topic', () => { expect( - town.addConversationArea({ id: 'Name1', topic: 'new topic', occupantsByID: [] }), + town.addConversationArea({ + id: 'Name1', + topic: 'new topic', + occupants: [], + type: 'ConversationArea', + }), ).toEqual(true); expect( - town.addConversationArea({ id: 'Name1', topic: 'new new topic', occupantsByID: [] }), + town.addConversationArea({ + id: 'Name1', + topic: 'new new topic', + occupants: [], + type: 'ConversationArea', + }), ).toEqual(false); }); describe('When successful', () => { @@ -611,7 +659,12 @@ describe('Town', () => { beforeEach(() => { playerTestData.moveTo(45, 122); // Inside of "Name1" area expect( - town.addConversationArea({ id: 'Name1', topic: newTopic, occupantsByID: [] }), + town.addConversationArea({ + id: 'Name1', + topic: newTopic, + occupants: [], + type: 'ConversationArea', + }), ).toEqual(true); }); it('Should update the local model for that area', () => { @@ -627,7 +680,8 @@ describe('Town', () => { expect(lastEmittedUpdate).toEqual({ id: 'Name1', topic: newTopic, - occupantsByID: [player.id], + occupants: [player.id], + type: 'ConversationArea', }); }); }); @@ -638,23 +692,58 @@ describe('Town', () => { }); it('Should return false if no area exists with that ID', () => { expect( - town.addViewingArea({ id: nanoid(), isPlaying: false, elapsedTimeSec: 0, video: nanoid() }), + town.addViewingArea({ + id: nanoid(), + isPlaying: false, + elapsedTimeSec: 0, + video: nanoid(), + occupants: [], + type: 'ViewingArea', + }), ).toBe(false); }); it('Should return false if the requested video is empty', () => { expect( - town.addViewingArea({ id: 'Name3', isPlaying: false, elapsedTimeSec: 0, video: '' }), + town.addViewingArea({ + id: 'Name3', + isPlaying: false, + elapsedTimeSec: 0, + video: '', + occupants: [], + type: 'ViewingArea', + }), ).toBe(false); expect( - town.addViewingArea({ id: 'Name3', isPlaying: false, elapsedTimeSec: 0, video: undefined }), + town.addViewingArea({ + id: 'Name3', + isPlaying: false, + elapsedTimeSec: 0, + video: undefined, + occupants: [], + type: 'ViewingArea', + }), ).toBe(false); }); it('Should return false if the area is already active', () => { expect( - town.addViewingArea({ id: 'Name3', isPlaying: false, elapsedTimeSec: 0, video: 'test' }), + town.addViewingArea({ + id: 'Name3', + isPlaying: false, + elapsedTimeSec: 0, + video: 'test', + occupants: [], + type: 'ViewingArea', + }), ).toBe(true); expect( - town.addViewingArea({ id: 'Name3', isPlaying: false, elapsedTimeSec: 0, video: 'test2' }), + town.addViewingArea({ + id: 'Name3', + isPlaying: false, + elapsedTimeSec: 0, + video: 'test2', + occupants: [], + type: 'ViewingArea', + }), ).toBe(false); }); describe('When successful', () => { @@ -663,10 +752,13 @@ describe('Town', () => { isPlaying: true, elapsedTimeSec: 100, video: nanoid(), + occupants: [playerID], + type: 'ViewingArea', }; beforeEach(() => { playerTestData.moveTo(160, 570); // Inside of "Name3" area expect(town.addViewingArea(newModel)).toBe(true); + newModel.occupants = [playerID]; }); it('Should update the local model for that area', () => { @@ -733,9 +825,14 @@ describe('Town', () => { beforeEach(async () => { town.initializeFromMap(testingMaps.twoConvOneViewing); playerTestData.moveTo(51, 121); - expect(town.addConversationArea({ id: 'Name1', topic: 'test', occupantsByID: [] })).toBe( - true, - ); + expect( + town.addConversationArea({ + id: 'Name1', + topic: 'test', + occupants: [], + type: 'ViewingArea', + }), + ).toBe(true); }); it('Adds a player to a new interactable and sets their conversation label, if they move into it', async () => { const newPlayer = mockPlayer(town.townID); diff --git a/townService/src/town/Town.ts b/townService/src/town/Town.ts index 14b2b8d06..d181c709e 100644 --- a/townService/src/town/Town.ts +++ b/townService/src/town/Town.ts @@ -1,6 +1,7 @@ import { ITiledMap, ITiledMapObjectLayer } from '@jonbell/tiled-map-type-guard'; import { nanoid } from 'nanoid'; import { BroadcastOperator } from 'socket.io'; +import InvalidParametersError from '../lib/InvalidParametersError'; import IVideoClient from '../lib/IVideoClient'; import Player from '../lib/Player'; import TwilioVideo from '../lib/TwilioVideo'; @@ -10,12 +11,16 @@ import { ConversationArea as ConversationAreaModel, CoveyTownSocket, Interactable, + InteractableCommand, + InteractableCommandBase, PlayerLocation, ServerToClientEvents, SocketData, ViewingArea as ViewingAreaModel, } from '../types/CoveyTownSocket'; +import { logError } from '../Utils'; import ConversationArea from './ConversationArea'; +import GameAreaFactory from './games/GameAreaFactory'; import InteractableArea from './InteractableArea'; import ViewingArea from './ViewingArea'; @@ -156,6 +161,49 @@ export default class Town { } } }); + + // Set up a listener to process commands to interactables. + // Dispatches commands to the appropriate interactable and sends the response back to the client + socket.on('interactableCommand', (command: InteractableCommand & InteractableCommandBase) => { + const interactable = this._interactables.find( + eachInteractable => eachInteractable.id === command.interactableID, + ); + if (interactable) { + try { + const payload = interactable.handleCommand(command, newPlayer); + socket.emit('commandResponse', { + commandID: command.commandID, + interactableID: command.interactableID, + isOK: true, + payload, + }); + } catch (err) { + if (err instanceof InvalidParametersError) { + socket.emit('commandResponse', { + commandID: command.commandID, + interactableID: command.interactableID, + isOK: false, + error: err.message, + }); + } else { + logError(err); + socket.emit('commandResponse', { + commandID: command.commandID, + interactableID: command.interactableID, + isOK: false, + error: 'Unknown error', + }); + } + } + } else { + socket.emit('commandResponse', { + commandID: command.commandID, + interactableID: command.interactableID, + isOK: false, + error: `No such interactable ${command.interactableID}`, + }); + } + }); return newPlayer; } @@ -352,7 +400,14 @@ export default class Town { ConversationArea.fromMapObject(eachConvAreaObj, this._broadcastEmitter), ); - this._interactables = this._interactables.concat(viewingAreas).concat(conversationAreas); + const gameAreas = objectLayer.objects + .filter(eachObject => eachObject.type === 'GameArea') + .map(eachGameAreaObj => GameAreaFactory(eachGameAreaObj, this._broadcastEmitter)); + + this._interactables = this._interactables + .concat(viewingAreas) + .concat(conversationAreas) + .concat(gameAreas); this._validateInteractables(); } diff --git a/townService/src/town/TownsController.test.ts b/townService/src/town/TownsController.test.ts index 878f8e198..4b788cba4 100644 --- a/townService/src/town/TownsController.test.ts +++ b/townService/src/town/TownsController.test.ts @@ -231,8 +231,8 @@ describe('TownsController integration tests', () => { const initialData = getLastEmittedEvent(player.socket, 'initialize'); const conversationArea = createConversationForTesting({ boundingBox: { x: 10, y: 10, width: 1, height: 1 }, - conversationID: initialData.interactables.find( - eachInteractable => 'occupantsByID' in eachInteractable, + conversationID: initialData.interactables.find(eachInteractable => + isConversationArea(eachInteractable), )?.id, }); await controller.createConversationArea( @@ -311,6 +311,8 @@ describe('TownsController integration tests', () => { id: viewingArea.id, video: nanoid(), isPlaying: true, + occupants: [], + type: 'ViewingArea', }; await controller.createViewingArea(testingTown.townID, sessionToken, newViewingArea); // Check to see that the viewing area was successfully updated @@ -330,6 +332,8 @@ describe('TownsController integration tests', () => { id: viewingArea.id, video: nanoid(), isPlaying: true, + occupants: [], + type: 'ViewingArea', }; await expect( controller.createViewingArea(nanoid(), sessionToken, newViewingArea), @@ -343,6 +347,8 @@ describe('TownsController integration tests', () => { id: viewingArea.id, video: nanoid(), isPlaying: true, + occupants: [], + type: 'ViewingArea', }; await expect( controller.createViewingArea(testingTown.townID, invalidSessionToken, newViewingArea), diff --git a/townService/src/town/TownsController.ts b/townService/src/town/TownsController.ts index a4b40cfe2..7be3111b0 100644 --- a/townService/src/town/TownsController.ts +++ b/townService/src/town/TownsController.ts @@ -118,13 +118,13 @@ export class TownsController extends Controller { public async createConversationArea( @Path() townID: string, @Header('X-Session-Token') sessionToken: string, - @Body() requestBody: ConversationArea, + @Body() requestBody: Omit, ): Promise { const town = this._townsStore.getTownByID(townID); if (!town?.getPlayerBySessionToken(sessionToken)) { throw new InvalidParametersError('Invalid values specified'); } - const success = town.addConversationArea(requestBody); + const success = town.addConversationArea({ ...requestBody, type: 'ConversationArea' }); if (!success) { throw new InvalidParametersError('Invalid values specified'); } @@ -146,7 +146,7 @@ export class TownsController extends Controller { public async createViewingArea( @Path() townID: string, @Header('X-Session-Token') sessionToken: string, - @Body() requestBody: ViewingArea, + @Body() requestBody: Omit, ): Promise { const town = this._townsStore.getTownByID(townID); if (!town) { @@ -155,7 +155,7 @@ export class TownsController extends Controller { if (!town?.getPlayerBySessionToken(sessionToken)) { throw new InvalidParametersError('Invalid values specified'); } - const success = town.addViewingArea(requestBody); + const success = town.addViewingArea({ ...requestBody, type: 'ViewingArea' }); if (!success) { throw new InvalidParametersError('Invalid values specified'); } diff --git a/townService/src/town/ViewingArea.test.ts b/townService/src/town/ViewingArea.test.ts index 93e504398..0415d101a 100644 --- a/townService/src/town/ViewingArea.test.ts +++ b/townService/src/town/ViewingArea.test.ts @@ -2,7 +2,7 @@ import { mock, mockClear } from 'jest-mock-extended'; import { nanoid } from 'nanoid'; import Player from '../lib/Player'; import { getLastEmittedEvent } from '../TestUtils'; -import { TownEmitter } from '../types/CoveyTownSocket'; +import { PlayerID, TownEmitter } from '../types/CoveyTownSocket'; import ViewingArea from './ViewingArea'; describe('ViewingArea', () => { @@ -14,10 +14,15 @@ describe('ViewingArea', () => { const isPlaying = true; const elapsedTimeSec = 10; const video = nanoid(); + const occupants: PlayerID[] = []; beforeEach(() => { mockClear(townEmitter); - testArea = new ViewingArea({ id, isPlaying, elapsedTimeSec, video }, testAreaBox, townEmitter); + testArea = new ViewingArea( + { id, isPlaying, elapsedTimeSec, video, occupants }, + testAreaBox, + townEmitter, + ); newPlayer = new Player(nanoid(), mock()); testArea.add(newPlayer); }); @@ -31,7 +36,14 @@ describe('ViewingArea', () => { expect(testArea.occupantsByID).toEqual([extraPlayer.id]); const lastEmittedUpdate = getLastEmittedEvent(townEmitter, 'interactableUpdate'); - expect(lastEmittedUpdate).toEqual({ id, isPlaying, elapsedTimeSec, video }); + expect(lastEmittedUpdate).toEqual({ + id, + isPlaying, + elapsedTimeSec, + video, + occupants: [extraPlayer.id], + type: 'ViewingArea', + }); }); it("Clears the player's conversationLabel and emits an update for their location", () => { testArea.remove(newPlayer); @@ -42,7 +54,14 @@ describe('ViewingArea', () => { it('Clears the video property when the last occupant leaves', () => { testArea.remove(newPlayer); const lastEmittedUpdate = getLastEmittedEvent(townEmitter, 'interactableUpdate'); - expect(lastEmittedUpdate).toEqual({ id, isPlaying, elapsedTimeSec, video: undefined }); + expect(lastEmittedUpdate).toEqual({ + id, + isPlaying, + elapsedTimeSec, + video: undefined, + occupants: [], + type: 'ViewingArea', + }); expect(testArea.video).toBeUndefined(); }); }); @@ -57,17 +76,26 @@ describe('ViewingArea', () => { expect(lastEmittedMovement.location.interactableID).toEqual(id); }); }); - test('toModel sets the ID, video, isPlaying and elapsedTimeSec', () => { + test('toModel sets the ID, video, isPlaying, occupants, and elapsedTimeSec', () => { const model = testArea.toModel(); expect(model).toEqual({ id, video, elapsedTimeSec, isPlaying, + occupants: [newPlayer.id], + type: 'ViewingArea', }); }); test('updateModel sets video, isPlaying and elapsedTimeSec', () => { - testArea.updateModel({ id: 'ignore', isPlaying: false, elapsedTimeSec: 150, video: 'test2' }); + testArea.updateModel({ + id: 'ignore', + isPlaying: false, + elapsedTimeSec: 150, + video: 'test2', + occupants: [], + type: 'ViewingArea', + }); expect(testArea.isPlaying).toBe(false); expect(testArea.id).toBe(id); expect(testArea.elapsedTimeSec).toBe(150); diff --git a/townService/src/town/ViewingArea.ts b/townService/src/town/ViewingArea.ts index 9f15d493d..bb036f803 100644 --- a/townService/src/town/ViewingArea.ts +++ b/townService/src/town/ViewingArea.ts @@ -1,9 +1,14 @@ import { ITiledMapObject } from '@jonbell/tiled-map-type-guard'; +import InvalidParametersError from '../lib/InvalidParametersError'; import Player from '../lib/Player'; import { BoundingBox, + InteractableCommand, + InteractableCommandReturnType, + InteractableID, TownEmitter, ViewingArea as ViewingAreaModel, + ViewingAreaUpdateCommand, } from '../types/CoveyTownSocket'; import InteractableArea from './InteractableArea'; @@ -34,7 +39,7 @@ export default class ViewingArea extends InteractableArea { * @param townEmitter a broadcast emitter that can be used to emit updates to players */ public constructor( - { id, isPlaying, elapsedTimeSec: progress, video }: ViewingAreaModel, + { id, isPlaying, elapsedTimeSec: progress, video }: Omit, coordinates: BoundingBox, townEmitter: TownEmitter, ) { @@ -81,6 +86,8 @@ export default class ViewingArea extends InteractableArea { video: this._video, isPlaying: this._isPlaying, elapsedTimeSec: this._elapsedTimeSec, + occupants: this.occupantsByID, + type: 'ViewingArea', }; } @@ -96,6 +103,21 @@ export default class ViewingArea extends InteractableArea { throw new Error(`Malformed viewing area ${name}`); } const rect: BoundingBox = { x: mapObject.x, y: mapObject.y, width, height }; - return new ViewingArea({ isPlaying: false, id: name, elapsedTimeSec: 0 }, rect, townEmitter); + return new ViewingArea( + { isPlaying: false, id: name as InteractableID, elapsedTimeSec: 0, occupants: [] }, + rect, + townEmitter, + ); + } + + public handleCommand( + command: CommandType, + ): InteractableCommandReturnType { + if (command.type === 'ViewingAreaUpdate') { + const viewingArea = command as ViewingAreaUpdateCommand; + this.updateModel(viewingArea.update); + return {} as InteractableCommandReturnType; + } + throw new InvalidParametersError('Unknown command type'); } } diff --git a/townService/src/town/games/Game.ts b/townService/src/town/games/Game.ts new file mode 100644 index 000000000..83bfab00a --- /dev/null +++ b/townService/src/town/games/Game.ts @@ -0,0 +1,94 @@ +import { nanoid } from 'nanoid'; +import Player from '../../lib/Player'; +import { + GameInstance, + GameInstanceID, + GameMove, + GameResult, + GameState, +} from '../../types/CoveyTownSocket'; + +/** + * This class is the base class for all games. It is responsible for managing the + * state of the game. @see GameArea + */ +export default abstract class Game { + private _state: StateType; + + public readonly id: GameInstanceID; + + protected _result?: GameResult; + + protected _players: Player[] = []; + + /** + * Creates a new Game instance. + * @param initialState State to initialize the game with. + * @param emitAreaChanged A callback to invoke when the state of the game changes. This is used to notify clients. + */ + public constructor(initialState: StateType) { + this.id = nanoid() as GameInstanceID; + this._state = initialState; + } + + public get state() { + return this._state; + } + + protected set state(newState: StateType) { + this._state = newState; + } + + /** + * Apply a move to the game. + * This method should be implemented by subclasses. + * @param move A move to apply to the game. + * @throws InvalidParametersError if the move is invalid. + */ + public abstract applyMove(move: GameMove): void; + + /** + * Attempt to join a game. + * This method should be implemented by subclasses. + * @param player The player to join the game. + * @throws InvalidParametersError if the player can not join the game + */ + protected abstract _join(player: Player): void; + + /** + * Attempt to leave a game. + * This method should be implemented by subclasses. + * @param player The player to leave the game. + * @throws InvalidParametersError if the player can not leave the game + */ + protected abstract _leave(player: Player): void; + + /** + * Attempt to join a game. + * @param player The player to join the game. + * @throws InvalidParametersError if the player can not join the game + */ + public join(player: Player): void { + this._join(player); + this._players.push(player); + } + + /** + * Attempt to leave a game. + * @param player The player to leave the game. + * @throws InvalidParametersError if the player can not leave the game + */ + public leave(player: Player): void { + this._leave(player); + this._players = this._players.filter(p => p.id !== player.id); + } + + public toModel(): GameInstance { + return { + state: this._state, + id: this.id, + result: this._result, + players: this._players.map(player => player.id), + }; + } +} diff --git a/townService/src/town/games/GameArea.ts b/townService/src/town/games/GameArea.ts new file mode 100644 index 000000000..1240810f7 --- /dev/null +++ b/townService/src/town/games/GameArea.ts @@ -0,0 +1,52 @@ +import Player from '../../lib/Player'; +import { + GameArea as GameAreaModel, + GameResult, + GameState, + InteractableType, +} from '../../types/CoveyTownSocket'; +import InteractableArea from '../InteractableArea'; +import Game from './Game'; + +/** + * A GameArea is an InteractableArea on the map that can host a game. + * At any given point in time, there is at most one game in progress in a GameArea. + */ +export default abstract class GameArea< + GameType extends Game, +> extends InteractableArea { + protected _game?: GameType; + + protected _history: GameResult[] = []; + + public get game(): GameType | undefined { + return this._game; + } + + public get history(): GameResult[] { + return this._history; + } + + public toModel(): GameAreaModel { + return { + id: this.id, + game: this._game?.toModel(), + history: this._history, + occupants: this.occupantsByID, + type: this.getType(), + }; + } + + public get isActive(): boolean { + return true; + } + + protected abstract getType(): InteractableType; + + public remove(player: Player): void { + if (this._game) { + this._game.leave(player); + } + super.remove(player); + } +} diff --git a/townService/src/town/games/GameAreaFactory.ts b/townService/src/town/games/GameAreaFactory.ts new file mode 100644 index 000000000..ccf45da59 --- /dev/null +++ b/townService/src/town/games/GameAreaFactory.ts @@ -0,0 +1,27 @@ +import { ITiledMapObject } from '@jonbell/tiled-map-type-guard'; +import { BoundingBox, TownEmitter } from '../../types/CoveyTownSocket'; +import InteractableArea from '../InteractableArea'; +import TicTacToeGameArea from './TicTacToeGameArea'; + +/** + * Creates a new GameArea from a map object + * @param mapObject the map object to create the game area from + * @param broadcastEmitter a broadcast emitter that can be used to emit updates to players + * @returns the interactable area + * @throws an error if the map object is malformed + */ +export default function GameAreaFactory( + mapObject: ITiledMapObject, + broadcastEmitter: TownEmitter, +): InteractableArea { + const { name, width, height } = mapObject; + if (!width || !height) { + throw new Error(`Malformed viewing area ${name}`); + } + const rect: BoundingBox = { x: mapObject.x, y: mapObject.y, width, height }; + const gameType = mapObject.properties?.find(prop => prop.name === 'type')?.value; + if (gameType === 'TicTacToe') { + return new TicTacToeGameArea(name, rect, broadcastEmitter); + } + throw new Error(`Unknown game area type ${mapObject.class}`); +} diff --git a/townService/src/town/games/TicTacToeGame.test.ts b/townService/src/town/games/TicTacToeGame.test.ts new file mode 100644 index 000000000..237580124 --- /dev/null +++ b/townService/src/town/games/TicTacToeGame.test.ts @@ -0,0 +1,455 @@ +import { createPlayerForTesting } from '../../TestUtils'; +import { + GAME_FULL_MESSAGE, + GAME_NOT_IN_PROGRESS_MESSAGE, + BOARD_POSITION_NOT_EMPTY_MESSAGE, + MOVE_NOT_YOUR_TURN_MESSAGE, + PLAYER_ALREADY_IN_GAME_MESSAGE, + PLAYER_NOT_IN_GAME_MESSAGE, +} from '../../lib/InvalidParametersError'; +import TicTacToeGame from './TicTacToeGame'; +import Player from '../../lib/Player'; +import { TicTacToeMove } from '../../types/CoveyTownSocket'; + +describe('TicTacToeGame', () => { + let game: TicTacToeGame; + + beforeEach(() => { + game = new TicTacToeGame(); + }); + + describe('[T1.1] _join', () => { + it('should throw an error if the player is already in the game', () => { + const player = createPlayerForTesting(); + game.join(player); + expect(() => game.join(player)).toThrowError(PLAYER_ALREADY_IN_GAME_MESSAGE); + const player2 = createPlayerForTesting(); + // TODO weaker test suite doesn't add this + game.join(player2); + expect(() => game.join(player2)).toThrowError(PLAYER_ALREADY_IN_GAME_MESSAGE); + }); + it('should throw an error if the game is full', () => { + const player1 = createPlayerForTesting(); + const player2 = createPlayerForTesting(); + const player3 = createPlayerForTesting(); + game.join(player1); + game.join(player2); + + expect(() => game.join(player3)).toThrowError(GAME_FULL_MESSAGE); + }); + describe('When the player can be added', () => { + it('makes the first player X and initializes the state with status WAITING_TO_START', () => { + const player = createPlayerForTesting(); + game.join(player); + expect(game.state.x).toEqual(player.id); + expect(game.state.o).toBeUndefined(); + expect(game.state.moves).toHaveLength(0); + expect(game.state.status).toEqual('WAITING_TO_START'); + expect(game.state.winner).toBeUndefined(); + }); + describe('When the second player joins', () => { + const player1 = createPlayerForTesting(); + const player2 = createPlayerForTesting(); + beforeEach(() => { + game.join(player1); + game.join(player2); + }); + it('makes the second player O', () => { + expect(game.state.x).toEqual(player1.id); + expect(game.state.o).toEqual(player2.id); + }); + it('sets the game status to IN_PROGRESS', () => { + expect(game.state.status).toEqual('IN_PROGRESS'); + expect(game.state.winner).toBeUndefined(); + expect(game.state.moves).toHaveLength(0); + }); + }); + }); + }); + describe('[T1.2] _leave', () => { + it('should throw an error if the player is not in the game', () => { + expect(() => game.leave(createPlayerForTesting())).toThrowError(PLAYER_NOT_IN_GAME_MESSAGE); + // TODO weaker test suite only does one of these - above or below + const player = createPlayerForTesting(); + game.join(player); + expect(() => game.leave(createPlayerForTesting())).toThrowError(PLAYER_NOT_IN_GAME_MESSAGE); + }); + describe('when the player is in the game', () => { + describe('when the game is in progress, it should set the game status to OVER and declare the other player the winner', () => { + test('when x leaves', () => { + const player1 = createPlayerForTesting(); + const player2 = createPlayerForTesting(); + game.join(player1); + game.join(player2); + expect(game.state.x).toEqual(player1.id); + expect(game.state.o).toEqual(player2.id); + + game.leave(player1); + + expect(game.state.status).toEqual('OVER'); + expect(game.state.winner).toEqual(player2.id); + expect(game.state.moves).toHaveLength(0); + + expect(game.state.x).toEqual(player1.id); + expect(game.state.o).toEqual(player2.id); + }); + test('when o leaves', () => { + const player1 = createPlayerForTesting(); + const player2 = createPlayerForTesting(); + game.join(player1); + game.join(player2); + expect(game.state.x).toEqual(player1.id); + expect(game.state.o).toEqual(player2.id); + + game.leave(player2); + + expect(game.state.status).toEqual('OVER'); + expect(game.state.winner).toEqual(player1.id); + expect(game.state.moves).toHaveLength(0); + + expect(game.state.x).toEqual(player1.id); + expect(game.state.o).toEqual(player2.id); + }); + }); + it('when the game is not in progress, it should set the game status to WAITING_TO_START and remove the player', () => { + const player1 = createPlayerForTesting(); + game.join(player1); + expect(game.state.x).toEqual(player1.id); + expect(game.state.o).toBeUndefined(); + expect(game.state.status).toEqual('WAITING_TO_START'); + expect(game.state.winner).toBeUndefined(); + game.leave(player1); + expect(game.state.x).toBeUndefined(); + expect(game.state.o).toBeUndefined(); + expect(game.state.status).toEqual('WAITING_TO_START'); + expect(game.state.winner).toBeUndefined(); + }); + }); + }); + describe('applyMove', () => { + let moves: TicTacToeMove[] = []; + + describe('[T2.2] when given an invalid move', () => { + it('should throw an error if the game is not in progress', () => { + const player1 = createPlayerForTesting(); + game.join(player1); + expect(() => + game.applyMove({ + gameID: game.id, + playerID: player1.id, + move: { + row: 0, + col: 0, + gamePiece: 'X', + }, + }), + ).toThrowError(GAME_NOT_IN_PROGRESS_MESSAGE); + }); + describe('when the game is in progress', () => { + let player1: Player; + let player2: Player; + beforeEach(() => { + player1 = createPlayerForTesting(); + player2 = createPlayerForTesting(); + game.join(player1); + game.join(player2); + expect(game.state.status).toEqual('IN_PROGRESS'); + }); + it('should rely on the player ID to determine whose turn it is', () => { + expect(() => + game.applyMove({ + gameID: game.id, + playerID: player2.id, + move: { + row: 0, + col: 0, + gamePiece: 'X', + }, + }), + ).toThrowError(MOVE_NOT_YOUR_TURN_MESSAGE); + expect(() => + game.applyMove({ + gameID: game.id, + playerID: player1.id, + move: { + row: 0, + col: 0, + gamePiece: 'O', + }, + }), + ).not.toThrowError(MOVE_NOT_YOUR_TURN_MESSAGE); + }); + it('should throw an error if the move is out of turn for the player ID', () => { + expect(() => + game.applyMove({ + gameID: game.id, + playerID: player2.id, + move: { + row: 0, + col: 0, + gamePiece: 'X', + }, + }), + ).toThrowError(MOVE_NOT_YOUR_TURN_MESSAGE); + game.applyMove({ + gameID: game.id, + playerID: player1.id, + move: { + row: 0, + col: 0, + gamePiece: 'X', + }, + }); + expect(() => + game.applyMove({ + gameID: game.id, + playerID: player1.id, + move: { + row: 0, + col: 1, + gamePiece: 'X', + }, + }), + ).toThrowError(MOVE_NOT_YOUR_TURN_MESSAGE); + // TODO this is a tricky one - the weaker test suite doesn't check that the player 2's move is out of turn after their first move + game.applyMove({ + gameID: game.id, + playerID: player2.id, + move: { + row: 0, + col: 2, + gamePiece: 'O', + }, + }); + expect(() => + game.applyMove({ + gameID: game.id, + playerID: player2.id, + move: { + row: 2, + col: 1, + gamePiece: 'O', + }, + }), + ).toThrowError(MOVE_NOT_YOUR_TURN_MESSAGE); + }); + it('should throw an error if the move is on an occupied space', () => { + const row = 0; + const col = 0; + game.applyMove({ + gameID: game.id, + playerID: player1.id, + move: { + row, + col, + gamePiece: 'X', + }, + }); + expect(() => + game.applyMove({ + gameID: game.id, + playerID: player2.id, + move: { + row, + col, + gamePiece: 'O', + }, + }), + ).toThrowError(BOARD_POSITION_NOT_EMPTY_MESSAGE); + }); + it('should not change whose turn it is when an invalid move is made', () => { + game.applyMove({ + gameID: game.id, + playerID: player1.id, + move: { + row: 1, + col: 1, + gamePiece: 'X', + }, + }); + expect(() => { + game.applyMove({ + gameID: game.id, + playerID: player2.id, + move: { + row: 1, + col: 1, + gamePiece: 'O', + }, + }); + }).toThrowError(BOARD_POSITION_NOT_EMPTY_MESSAGE); + expect(game.state.moves).toHaveLength(1); + game.applyMove({ + gameID: game.id, + playerID: player2.id, + move: { + row: 1, + col: 2, + gamePiece: 'O', + }, + }); + expect(game.state.moves).toHaveLength(2); + }); + it('should not prevent the reuse of a space after an invalid move on it', () => { + expect(() => { + game.applyMove({ + gameID: game.id, + playerID: player2.id, + move: { + row: 1, + col: 1, + gamePiece: 'O', + }, + }); + }).toThrowError(MOVE_NOT_YOUR_TURN_MESSAGE); + game.applyMove({ + gameID: game.id, + playerID: player1.id, + move: { + row: 1, + col: 1, + gamePiece: 'X', + }, + }); + }); + }); + }); + describe('when given a valid move', () => { + let player1: Player; + let player2: Player; + let numMoves = 0; + beforeEach(() => { + player1 = createPlayerForTesting(); + player2 = createPlayerForTesting(); + numMoves = 0; + moves = []; + game.join(player1); + game.join(player2); + expect(game.state.status).toEqual('IN_PROGRESS'); + }); + function makeMoveAndCheckState( + row: 0 | 1 | 2, + col: 0 | 1 | 2, + gamePiece: 'X' | 'O', + expectedOutcome: 'WIN' | 'TIE' | undefined = undefined, + ) { + game.applyMove({ + gameID: game.id, + playerID: gamePiece === 'X' ? player1.id : player2.id, + move: { + row, + col, + gamePiece, + }, + }); + moves.push({ row, col, gamePiece }); + expect(game.state.moves).toHaveLength(++numMoves); + for (let i = 0; i < numMoves; i++) { + expect(game.state.moves[i]).toEqual(moves[i]); + } + if (expectedOutcome === 'WIN') { + expect(game.state.status).toEqual('OVER'); + expect(game.state.winner).toEqual(gamePiece === 'X' ? player1.id : player2.id); + } else if (expectedOutcome === 'TIE') { + expect(game.state.status).toEqual('OVER'); + expect(game.state.winner).toBeUndefined(); + } else { + expect(game.state.status).toEqual('IN_PROGRESS'); + expect(game.state.winner).toBeUndefined(); + } + } + it('[T2.1] should add the move to the game state', () => { + makeMoveAndCheckState(1, 2, 'X'); + }); + it('[T2.1] should not end the game if the move does not end the game', () => { + makeMoveAndCheckState(1, 2, 'X'); + makeMoveAndCheckState(1, 0, 'O'); + makeMoveAndCheckState(0, 2, 'X'); + makeMoveAndCheckState(2, 2, 'O'); + makeMoveAndCheckState(1, 1, 'X'); + makeMoveAndCheckState(2, 0, 'O'); + }); + describe('[T2.3] when the move ends the game', () => { + describe('it checks for winning conditions', () => { + describe('a horizontal win', () => { + test('x wins', () => { + makeMoveAndCheckState(0, 0, 'X'); + makeMoveAndCheckState(1, 0, 'O'); + makeMoveAndCheckState(0, 1, 'X'); + makeMoveAndCheckState(1, 1, 'O'); + makeMoveAndCheckState(0, 2, 'X', 'WIN'); + }); + test('o wins', () => { + makeMoveAndCheckState(0, 0, 'X'); + makeMoveAndCheckState(1, 0, 'O'); + makeMoveAndCheckState(0, 1, 'X'); + makeMoveAndCheckState(1, 1, 'O'); + makeMoveAndCheckState(2, 0, 'X'); + makeMoveAndCheckState(1, 2, 'O', 'WIN'); + }); + }); + describe('a vertical win', () => { + test('x wins', () => { + makeMoveAndCheckState(0, 0, 'X'); + makeMoveAndCheckState(0, 1, 'O'); + makeMoveAndCheckState(1, 0, 'X'); + makeMoveAndCheckState(1, 1, 'O'); + makeMoveAndCheckState(2, 0, 'X', 'WIN'); + }); + test('o wins', () => { + makeMoveAndCheckState(0, 0, 'X'); + makeMoveAndCheckState(0, 1, 'O'); + makeMoveAndCheckState(1, 0, 'X'); + makeMoveAndCheckState(1, 1, 'O'); + makeMoveAndCheckState(2, 2, 'X'); + makeMoveAndCheckState(2, 1, 'O', 'WIN'); + }); + }); + describe('a diagonal win', () => { + test('x wins', () => { + makeMoveAndCheckState(0, 0, 'X'); + makeMoveAndCheckState(0, 1, 'O'); + makeMoveAndCheckState(1, 1, 'X'); + makeMoveAndCheckState(1, 2, 'O'); + makeMoveAndCheckState(2, 2, 'X', 'WIN'); + }); + test('o wins', () => { + makeMoveAndCheckState(0, 1, 'X'); + makeMoveAndCheckState(0, 0, 'O'); + makeMoveAndCheckState(1, 0, 'X'); + makeMoveAndCheckState(1, 1, 'O'); + makeMoveAndCheckState(2, 0, 'X'); + makeMoveAndCheckState(2, 2, 'O', 'WIN'); + }); + test('other diagonal - x wins', () => { + makeMoveAndCheckState(0, 2, 'X'); + makeMoveAndCheckState(0, 1, 'O'); + makeMoveAndCheckState(1, 1, 'X'); + makeMoveAndCheckState(1, 2, 'O'); + makeMoveAndCheckState(2, 0, 'X', 'WIN'); + }); + test('other diagonal - o wins', () => { + makeMoveAndCheckState(0, 1, 'X'); + makeMoveAndCheckState(0, 2, 'O'); + makeMoveAndCheckState(1, 0, 'X'); + makeMoveAndCheckState(1, 1, 'O'); + makeMoveAndCheckState(2, 1, 'X'); + makeMoveAndCheckState(2, 0, 'O', 'WIN'); + }); + }); + }); + it('declares a tie if there are no winning conditions but the board is full', () => { + makeMoveAndCheckState(0, 0, 'X'); + makeMoveAndCheckState(0, 1, 'O'); + makeMoveAndCheckState(0, 2, 'X'); + makeMoveAndCheckState(2, 0, 'O'); + makeMoveAndCheckState(1, 1, 'X'); + makeMoveAndCheckState(1, 2, 'O'); + makeMoveAndCheckState(1, 0, 'X'); + makeMoveAndCheckState(2, 2, 'O'); + makeMoveAndCheckState(2, 1, 'X', 'TIE'); + }); + }); + }); + }); +}); diff --git a/townService/src/town/games/TicTacToeGame.ts b/townService/src/town/games/TicTacToeGame.ts new file mode 100644 index 000000000..d015c557c --- /dev/null +++ b/townService/src/town/games/TicTacToeGame.ts @@ -0,0 +1,224 @@ +import InvalidParametersError, { + GAME_FULL_MESSAGE, + GAME_NOT_IN_PROGRESS_MESSAGE, + BOARD_POSITION_NOT_EMPTY_MESSAGE, + MOVE_NOT_YOUR_TURN_MESSAGE, + PLAYER_ALREADY_IN_GAME_MESSAGE, + PLAYER_NOT_IN_GAME_MESSAGE, +} from '../../lib/InvalidParametersError'; +import Player from '../../lib/Player'; +import { GameMove, TicTacToeGameState, TicTacToeMove } from '../../types/CoveyTownSocket'; +import Game from './Game'; + +/** + * A TicTacToeGame is a Game that implements the rules of Tic Tac Toe. + * @see https://en.wikipedia.org/wiki/Tic-tac-toe + */ +export default class TicTacToeGame extends Game { + public constructor() { + super({ + moves: [], + status: 'WAITING_TO_START', + }); + } + + private get _board() { + const { moves } = this.state; + const board = [ + ['', '', ''], + ['', '', ''], + ['', '', ''], + ]; + for (const move of moves) { + board[move.row][move.col] = move.gamePiece; + } + return board; + } + + private _checkForGameEnding() { + const board = this._board; + // A game ends when there are 3 in a row + // Check for 3 in a row or column + for (let i = 0; i < 3; i++) { + if (board[i][0] !== '' && board[i][0] === board[i][1] && board[i][0] === board[i][2]) { + this.state = { + ...this.state, + status: 'OVER', + winner: board[i][0] === 'X' ? this.state.x : this.state.o, + }; + return; + } + if (board[0][i] !== '' && board[0][i] === board[1][i] && board[0][i] === board[2][i]) { + this.state = { + ...this.state, + status: 'OVER', + winner: board[0][i] === 'X' ? this.state.x : this.state.o, + }; + return; + } + } + // Check for 3 in a diagonal + if (board[0][0] !== '' && board[0][0] === board[1][1] && board[0][0] === board[2][2]) { + this.state = { + ...this.state, + status: 'OVER', + winner: board[0][0] === 'X' ? this.state.x : this.state.o, + }; + return; + } + // Check for 3 in the other diagonal + if (board[0][2] !== '' && board[0][2] === board[1][1] && board[0][2] === board[2][0]) { + this.state = { + ...this.state, + status: 'OVER', + winner: board[0][2] === 'X' ? this.state.x : this.state.o, + }; + return; + } + // Check for no more moves + if (this.state.moves.length === 9) { + this.state = { + ...this.state, + status: 'OVER', + winner: undefined, + }; + } + } + + private _validateMove(move: TicTacToeMove) { + // A move is valid if the space is empty + for (const m of this.state.moves) { + if (m.col === move.col && m.row === move.row) { + throw new InvalidParametersError(BOARD_POSITION_NOT_EMPTY_MESSAGE); + } + } + + // A move is only valid if it is the player's turn + if (move.gamePiece === 'X' && this.state.moves.length % 2 === 1) { + throw new InvalidParametersError(MOVE_NOT_YOUR_TURN_MESSAGE); + } else if (move.gamePiece === 'O' && this.state.moves.length % 2 === 0) { + throw new InvalidParametersError(MOVE_NOT_YOUR_TURN_MESSAGE); + } + // A move is valid only if game is in progress + if (this.state.status !== 'IN_PROGRESS') { + throw new InvalidParametersError(GAME_NOT_IN_PROGRESS_MESSAGE); + } + } + + private _applyMove(move: TicTacToeMove): void { + this.state = { + ...this.state, + moves: [...this.state.moves, move], + }; + this._checkForGameEnding(); + } + + /* + * Applies a player's move to the game. + * Uses the player's ID to determine which game piece they are using (ignores move.gamePiece) + * Validates the move before applying it. If the move is invalid, throws an InvalidParametersError with + * the error message specified below. + * A move is invalid if: + * - The move is out of bounds (not in the 3x3 grid - use MOVE_OUT_OF_BOUNDS_MESSAGE) + * - The move is on a space that is already occupied (use BOARD_POSITION_NOT_EMPTY_MESSAGE) + * - The move is not the player's turn (MOVE_NOT_YOUR_TURN_MESSAGE) + * - The game is not in progress (GAME_NOT_IN_PROGRESS_MESSAGE) + * + * If the move is valid, applies the move to the game and updates the game state. + * + * If the move ends the game, updates the game's state. + * If the move results in a tie, updates the game's state to set the status to OVER and sets winner to undefined. + * If the move results in a win, updates the game's state to set the status to OVER and sets the winner to the player who made the move. + * A player wins if they have 3 in a row (horizontally, vertically, or diagonally). + * + * @param move The move to apply to the game + * @throws InvalidParametersError if the move is invalid + */ + public applyMove(move: GameMove): void { + let gamePiece: 'X' | 'O'; + if (move.playerID === this.state.x) { + gamePiece = 'X'; + } else { + gamePiece = 'O'; + } + const cleanMove = { + gamePiece, + col: move.move.col, + row: move.move.row, + }; + this._validateMove(cleanMove); + this._applyMove(cleanMove); + } + + /** + * Adds a player to the game. + * Updates the game's state to reflect the new player. + * If the game is now full (has two players), updates the game's state to set the status to IN_PROGRESS. + * + * @param player The player to join the game + * @throws InvalidParametersError if the player is already in the game (PLAYER_ALREADY_IN_GAME_MESSAGE) + * or the game is full (GAME_FULL_MESSAGE) + */ + protected _join(player: Player): void { + if (this.state.x === player.id || this.state.o === player.id) { + throw new InvalidParametersError(PLAYER_ALREADY_IN_GAME_MESSAGE); + } + if (!this.state.x) { + this.state = { + ...this.state, + x: player.id, + }; + } else if (!this.state.o) { + this.state = { + ...this.state, + o: player.id, + }; + } else { + throw new InvalidParametersError(GAME_FULL_MESSAGE); + } + if (this.state.x && this.state.o) { + this.state = { + ...this.state, + status: 'IN_PROGRESS', + }; + } + } + + /** + * Removes a player from the game. + * Updates the game's state to reflect the player leaving. + * If the game has two players in it at the time of call to this method, + * updates the game's status to OVER and sets the winner to the other player. + * If the game does not yet have two players in it at the time of call to this method, + * updates the game's status to WAITING_TO_START. + * + * @param player The player to remove from the game + * @throws InvalidParametersError if the player is not in the game (PLAYER_NOT_IN_GAME_MESSAGE) + */ + protected _leave(player: Player): void { + if (this.state.x !== player.id && this.state.o !== player.id) { + throw new InvalidParametersError(PLAYER_NOT_IN_GAME_MESSAGE); + } + // Handles case where the game has not started yet + if (this.state.o === undefined) { + this.state = { + moves: [], + status: 'WAITING_TO_START', + }; + return; + } + if (this.state.x === player.id) { + this.state = { + ...this.state, + status: 'OVER', + winner: this.state.o, + }; + } else { + this.state = { + ...this.state, + status: 'OVER', + winner: this.state.x, + }; + } + } +} diff --git a/townService/src/town/games/TicTacToeGameArea.test.ts b/townService/src/town/games/TicTacToeGameArea.test.ts new file mode 100644 index 000000000..72f06715a --- /dev/null +++ b/townService/src/town/games/TicTacToeGameArea.test.ts @@ -0,0 +1,307 @@ +import { mock } from 'jest-mock-extended'; +import { nanoid } from 'nanoid'; +import { createPlayerForTesting } from '../../TestUtils'; +import { + GAME_ID_MISSMATCH_MESSAGE, + GAME_NOT_IN_PROGRESS_MESSAGE, + INVALID_COMMAND_MESSAGE, +} from '../../lib/InvalidParametersError'; +import Player from '../../lib/Player'; +import { + GameInstanceID, + TicTacToeGameState, + TicTacToeMove, + TownEmitter, +} from '../../types/CoveyTownSocket'; +import TicTacToeGameArea from './TicTacToeGameArea'; +import * as TicTacToeGameModule from './TicTacToeGame'; +import Game from './Game'; + +class TestingGame extends Game { + public constructor() { + super({ + moves: [], + status: 'WAITING_TO_START', + }); + } + + public applyMove(): void {} + + public endGame(winner?: string) { + this.state = { + ...this.state, + status: 'OVER', + winner, + }; + } + + protected _join(player: Player): void { + if (this.state.x) { + this.state.o = player.id; + } else { + this.state.x = player.id; + } + this._players.push(player); + } + + protected _leave(): void {} +} +describe('TicTacToeGameArea', () => { + let gameArea: TicTacToeGameArea; + let player1: Player; + let player2: Player; + let interactableUpdateSpy: jest.SpyInstance; + let game: TestingGame; + beforeEach(() => { + const gameConstructorSpy = jest.spyOn(TicTacToeGameModule, 'default'); + game = new TestingGame(); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore (Testing without using the real game class) + gameConstructorSpy.mockReturnValue(game); + + player1 = createPlayerForTesting(); + player2 = createPlayerForTesting(); + gameArea = new TicTacToeGameArea( + nanoid(), + { x: 0, y: 0, width: 100, height: 100 }, + mock(), + ); + gameArea.add(player1); + gameArea.add(player2); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore (Test requires access to protected method) + interactableUpdateSpy = jest.spyOn(gameArea, '_emitAreaChanged'); + }); + describe('handleCommand', () => { + describe('[T3.1] when given a JoinGame command', () => { + describe('when there is no game in progress', () => { + it('should create a new game and call _emitAreaChanged', () => { + const { gameID } = gameArea.handleCommand({ type: 'JoinGame' }, player1); + expect(gameID).toBeDefined(); + if (!game) { + throw new Error('Game was not created by the first call to join'); + } + expect(gameID).toEqual(game.id); + expect(interactableUpdateSpy).toHaveBeenCalledTimes(1); + }); + }); + describe('when there is a game in progress', () => { + it('should dispatch the join command to the game and call _emitAreaChanged', () => { + const { gameID } = gameArea.handleCommand({ type: 'JoinGame' }, player1); + if (!game) { + throw new Error('Game was not created by the first call to join'); + } + expect(interactableUpdateSpy).toHaveBeenCalledTimes(1); + + const joinSpy = jest.spyOn(game, 'join'); + const gameID2 = gameArea.handleCommand({ type: 'JoinGame' }, player2).gameID; + expect(joinSpy).toHaveBeenCalledWith(player2); + expect(gameID).toEqual(gameID2); + expect(interactableUpdateSpy).toHaveBeenCalledTimes(2); + }); + it('should not call _emitAreaChanged if the game throws an error', () => { + gameArea.handleCommand({ type: 'JoinGame' }, player1); + if (!game) { + throw new Error('Game was not created by the first call to join'); + } + interactableUpdateSpy.mockClear(); + + const joinSpy = jest.spyOn(game, 'join').mockImplementationOnce(() => { + throw new Error('Test Error'); + }); + expect(() => gameArea.handleCommand({ type: 'JoinGame' }, player2)).toThrowError( + 'Test Error', + ); + expect(joinSpy).toHaveBeenCalledWith(player2); + expect(interactableUpdateSpy).not.toHaveBeenCalled(); + }); + }); + }); + describe('[T3.2] when given a GameMove command', () => { + it('should throw an error when there is no game in progress', () => { + expect(() => + gameArea.handleCommand( + { type: 'GameMove', move: { col: 0, row: 0, gamePiece: 'X' }, gameID: nanoid() }, + player1, + ), + ).toThrowError(GAME_NOT_IN_PROGRESS_MESSAGE); + }); + describe('when there is a game in progress', () => { + let gameID: GameInstanceID; + beforeEach(() => { + gameID = gameArea.handleCommand({ type: 'JoinGame' }, player1).gameID; + gameArea.handleCommand({ type: 'JoinGame' }, player2); + interactableUpdateSpy.mockClear(); + }); + it('should throw an error when the game ID does not match', () => { + expect(() => + gameArea.handleCommand( + { type: 'GameMove', move: { col: 0, row: 0, gamePiece: 'X' }, gameID: nanoid() }, + player1, + ), + ).toThrowError(GAME_ID_MISSMATCH_MESSAGE); + }); + it('should dispatch the move to the game and call _emitAreaChanged', () => { + const move: TicTacToeMove = { col: 0, row: 0, gamePiece: 'X' }; + const applyMoveSpy = jest.spyOn(game, 'applyMove'); + gameArea.handleCommand({ type: 'GameMove', move, gameID }, player1); + expect(applyMoveSpy).toHaveBeenCalledWith({ + gameID: game.id, + playerID: player1.id, + move: { + ...move, + gamePiece: 'X', + }, + }); + expect(interactableUpdateSpy).toHaveBeenCalledTimes(1); + }); + it('should not call _emitAreaChanged if the game throws an error', () => { + const move: TicTacToeMove = { col: 0, row: 0, gamePiece: 'X' }; + const applyMoveSpy = jest.spyOn(game, 'applyMove').mockImplementationOnce(() => { + throw new Error('Test Error'); + }); + expect(() => + gameArea.handleCommand({ type: 'GameMove', move, gameID }, player1), + ).toThrowError('Test Error'); + expect(applyMoveSpy).toHaveBeenCalledWith({ + gameID: game.id, + playerID: player1.id, + move: { + ...move, + gamePiece: 'X', + }, + }); + expect(interactableUpdateSpy).not.toHaveBeenCalled(); + }); + describe('when the game is over, it records a new row in the history and calls _emitAreaChanged', () => { + test('when X wins', () => { + const move: TicTacToeMove = { col: 0, row: 0, gamePiece: 'X' }; + jest.spyOn(game, 'applyMove').mockImplementationOnce(() => { + game.endGame(player1.id); + }); + gameArea.handleCommand({ type: 'GameMove', move, gameID }, player1); + expect(game.state.status).toEqual('OVER'); + expect(gameArea.history.length).toEqual(1); + expect(gameArea.history[0]).toEqual({ + gameID: game.id, + scores: { + [player1.userName]: 1, + [player2.userName]: 0, + }, + }); + expect(interactableUpdateSpy).toHaveBeenCalledTimes(1); + }); + test('when O wins', () => { + const move: TicTacToeMove = { col: 0, row: 0, gamePiece: 'O' }; + jest.spyOn(game, 'applyMove').mockImplementationOnce(() => { + game.endGame(player2.id); + }); + gameArea.handleCommand({ type: 'GameMove', move, gameID }, player2); + expect(game.state.status).toEqual('OVER'); + expect(gameArea.history.length).toEqual(1); + expect(gameArea.history[0]).toEqual({ + gameID: game.id, + scores: { + [player1.userName]: 0, + [player2.userName]: 1, + }, + }); + expect(interactableUpdateSpy).toHaveBeenCalledTimes(1); + }); + test('when there is a tie', () => { + const move: TicTacToeMove = { col: 0, row: 0, gamePiece: 'X' }; + jest.spyOn(game, 'applyMove').mockImplementationOnce(() => { + game.endGame(); + }); + gameArea.handleCommand({ type: 'GameMove', move, gameID }, player1); + expect(game.state.status).toEqual('OVER'); + expect(gameArea.history.length).toEqual(1); + expect(gameArea.history[0]).toEqual({ + gameID: game.id, + scores: { + [player1.userName]: 0, + [player2.userName]: 0, + }, + }); + expect(interactableUpdateSpy).toHaveBeenCalledTimes(1); + }); + }); + }); + }); + describe('[T3.3] when given a LeaveGame command', () => { + describe('when there is no game in progress', () => { + it('should throw an error', () => { + expect(() => + gameArea.handleCommand({ type: 'LeaveGame', gameID: nanoid() }, player1), + ).toThrowError(GAME_NOT_IN_PROGRESS_MESSAGE); + expect(interactableUpdateSpy).not.toHaveBeenCalled(); + }); + }); + describe('when there is a game in progress', () => { + it('should throw an error when the game ID does not match', () => { + gameArea.handleCommand({ type: 'JoinGame' }, player1); + interactableUpdateSpy.mockClear(); + expect(() => + gameArea.handleCommand({ type: 'LeaveGame', gameID: nanoid() }, player1), + ).toThrowError(GAME_ID_MISSMATCH_MESSAGE); + expect(interactableUpdateSpy).not.toHaveBeenCalled(); + }); + it('should dispatch the leave command to the game and call _emitAreaChanged', () => { + const { gameID } = gameArea.handleCommand({ type: 'JoinGame' }, player1); + if (!game) { + throw new Error('Game was not created by the first call to join'); + } + expect(interactableUpdateSpy).toHaveBeenCalledTimes(1); + const leaveSpy = jest.spyOn(game, 'leave'); + gameArea.handleCommand({ type: 'LeaveGame', gameID }, player1); + expect(leaveSpy).toHaveBeenCalledWith(player1); + expect(interactableUpdateSpy).toHaveBeenCalledTimes(2); + }); + it('should not call _emitAreaChanged if the game throws an error', () => { + gameArea.handleCommand({ type: 'JoinGame' }, player1); + if (!game) { + throw new Error('Game was not created by the first call to join'); + } + interactableUpdateSpy.mockClear(); + const leaveSpy = jest.spyOn(game, 'leave').mockImplementationOnce(() => { + throw new Error('Test Error'); + }); + expect(() => + gameArea.handleCommand({ type: 'LeaveGame', gameID: game.id }, player1), + ).toThrowError('Test Error'); + expect(leaveSpy).toHaveBeenCalledWith(player1); + expect(interactableUpdateSpy).not.toHaveBeenCalled(); + }); + it('should update the history if the game is over', () => { + const { gameID } = gameArea.handleCommand({ type: 'JoinGame' }, player1); + gameArea.handleCommand({ type: 'JoinGame' }, player2); + interactableUpdateSpy.mockClear(); + jest.spyOn(game, 'leave').mockImplementationOnce(() => { + game.endGame(player1.id); + }); + gameArea.handleCommand({ type: 'LeaveGame', gameID }, player1); + expect(game.state.status).toEqual('OVER'); + expect(gameArea.history.length).toEqual(1); + expect(gameArea.history[0]).toEqual({ + gameID: game.id, + scores: { + [player1.userName]: 1, + [player2.userName]: 0, + }, + }); + expect(interactableUpdateSpy).toHaveBeenCalledTimes(1); + }); + }); + }); + describe('[T3.4] when given an invalid command', () => { + it('should throw an error', () => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore (Testing an invalid command, only possible at the boundary of the type system) + expect(() => gameArea.handleCommand({ type: 'InvalidCommand' }, player1)).toThrowError( + INVALID_COMMAND_MESSAGE, + ); + expect(interactableUpdateSpy).not.toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/townService/src/town/games/TicTacToeGameArea.ts b/townService/src/town/games/TicTacToeGameArea.ts new file mode 100644 index 000000000..614b4f8e2 --- /dev/null +++ b/townService/src/town/games/TicTacToeGameArea.ts @@ -0,0 +1,116 @@ +import InvalidParametersError, { + GAME_ID_MISSMATCH_MESSAGE, + GAME_NOT_IN_PROGRESS_MESSAGE, + INVALID_COMMAND_MESSAGE, +} from '../../lib/InvalidParametersError'; +import Player from '../../lib/Player'; +import { + GameInstance, + InteractableCommand, + InteractableCommandReturnType, + InteractableType, + TicTacToeGameState, +} from '../../types/CoveyTownSocket'; +import GameArea from './GameArea'; +import TicTacToeGame from './TicTacToeGame'; + +/** + * A TicTacToeGameArea is a GameArea that hosts a TicTacToeGame. + * @see TicTacToeGame + * @see GameArea + */ +export default class TicTacToeGameArea extends GameArea { + protected getType(): InteractableType { + return 'TicTacToeArea'; + } + + private _stateUpdated(updatedState: GameInstance) { + if (updatedState.state.status === 'OVER') { + // If we haven't yet recorded the outcome, do so now. + const gameID = this._game?.id; + if (gameID && !this._history.find(eachResult => eachResult.gameID === gameID)) { + const { x, o } = updatedState.state; + if (x && o) { + const xName = this._occupants.find(eachPlayer => eachPlayer.id === x)?.userName || x; + const oName = this._occupants.find(eachPlayer => eachPlayer.id === o)?.userName || o; + this._history.push({ + gameID, + scores: { + [xName]: updatedState.state.winner === x ? 1 : 0, + [oName]: updatedState.state.winner === o ? 1 : 0, + }, + }); + } + } + } + this._emitAreaChanged(); + } + + /** + * Handle a command from a player in this game area. + * Supported commands: + * - JoinGame (joins the game `this._game`, or creates a new one if none is in progress) + * - GameMove (applies a move to the game) + * - LeaveGame (leaves the game) + * + * If the command ended the game, records the outcome in this._history + * If the command is successful (does not throw an error), calls this._emitAreaChanged (necessary + * to notify any listeners of a state update, including any change to history) + * If the command is unsuccessful (throws an error), the error is propagated to the caller + * + * @see InteractableCommand + * + * @param command command to handle + * @param player player making the request + * @returns response to the command, @see InteractableCommandResponse + * @throws InvalidParametersError if the command is not supported or is invalid. Invalid commands: + * - LeaveGame and GameMove: No game in progress (GAME_NOT_IN_PROGRESS_MESSAGE), + * or gameID does not match the game in progress (GAME_ID_MISSMATCH_MESSAGE) + * - Any command besides LeaveGame, GameMove and JoinGame: INVALID_COMMAND_MESSAGE + */ + public handleCommand( + command: CommandType, + player: Player, + ): InteractableCommandReturnType { + if (command.type === 'GameMove') { + const game = this._game; + if (!game) { + throw new InvalidParametersError(GAME_NOT_IN_PROGRESS_MESSAGE); + } + if (this._game?.id !== command.gameID) { + throw new InvalidParametersError(GAME_ID_MISSMATCH_MESSAGE); + } + game.applyMove({ + gameID: command.gameID, + playerID: player.id, + move: command.move, + }); + this._stateUpdated(game.toModel()); + return undefined as InteractableCommandReturnType; + } + if (command.type === 'JoinGame') { + let game = this._game; + if (!game || game.state.status === 'OVER') { + // No game in progress, make a new one + game = new TicTacToeGame(); + this._game = game; + } + game.join(player); + this._stateUpdated(game.toModel()); + return { gameID: game.id } as InteractableCommandReturnType; + } + if (command.type === 'LeaveGame') { + const game = this._game; + if (!game) { + throw new InvalidParametersError(GAME_NOT_IN_PROGRESS_MESSAGE); + } + if (this._game?.id !== command.gameID) { + throw new InvalidParametersError(GAME_ID_MISSMATCH_MESSAGE); + } + game.leave(player); + this._stateUpdated(game.toModel()); + return undefined as InteractableCommandReturnType; + } + throw new InvalidParametersError(INVALID_COMMAND_MESSAGE); + } +} diff --git a/townService/stryker.conf.json b/townService/stryker.conf.json index df2ec499d..e5216b08c 100644 --- a/townService/stryker.conf.json +++ b/townService/stryker.conf.json @@ -18,7 +18,7 @@ "configFile": "jest.config.cjs" }, "mutate": [ - "src/town/Town.ts:148-161", - "src/town/TownsController.ts:146-159" + "src/town/games/TicTacToeGame.ts:39-216", + "src/town/games/TicTacToeGameArea.ts:27-114" ] -} \ No newline at end of file +} diff --git a/townService/tsconfig.json b/townService/tsconfig.json index 28e3e9557..301706697 100644 --- a/townService/tsconfig.json +++ b/townService/tsconfig.json @@ -43,7 +43,7 @@ /* Additional Checks */ "noUnusedLocals": false /* Report errors on unused locals. */, - "noUnusedParameters": true /* Report errors on unused parameters. */, + "noUnusedParameters": false /* Report errors on unused parameters. */, "noImplicitReturns": true /* Report error when not all code paths in function return a value. */, "noFallthroughCasesInSwitch": true /* Report errors for fallthrough cases in switch statement. */,