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)}
/>
- handleJoin(townIDToJoin)}>
+ handleJoin(townIDToJoin)}
+ isLoading={isJoining}
+ disabled={isJoining}>
Connect
@@ -264,7 +345,8 @@ export default function TownSelection(): JSX.Element {
{town.currentOccupancy}/{town.maximumOccupancy}
handleJoin(town.townID)}
- disabled={town.currentOccupancy >= town.maximumOccupancy}>
+ disabled={town.currentOccupancy >= town.maximumOccupancy || isJoining}
+ isLoading={isJoining}>
Connect
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 (
+
+
+
+ Player |
+ Wins |
+ Losses |
+ Ties |
+
+
+
+ {rows.map(record => {
+ return (
+
+ {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 = (
+ {
+ setJoiningGame(true);
+ try {
+ await gameAreaController.joinGame();
+ } catch (err) {
+ toast({
+ title: 'Error joining game',
+ description: (err as Error).toString(),
+ status: 'error',
+ });
+ }
+ setJoiningGame(false);
+ }}
+ isLoading={joiningGame}
+ disabled={joiningGame}>
+ Join New Game
+
+ );
+ }
+ 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 && (
- )}
+ )} */}
{flipCameraSupported && (
+ {observers.map(player => { + return{player.userName} ;
+ })}
+
++X: {x?.userName || '(No player yet!)'}
+ O: {o?.userName || '(No player yet!)'}
+
+