├── README.md ├── .prettierrc ├── src ├── backend │ ├── nest-cli.json │ ├── .env.dev │ ├── tsconfig.build.json │ ├── src │ │ ├── round │ │ │ ├── round.controller.ts │ │ │ ├── round.service.spec.ts │ │ │ ├── round.controller.spec.ts │ │ │ └── round.service.ts │ │ ├── main.ts │ │ ├── entities │ │ │ ├── setting.entity.ts │ │ │ ├── round.entity.ts │ │ │ ├── player.entity.ts │ │ │ ├── grab.entity.ts │ │ │ └── game.entity.ts │ │ ├── admin │ │ │ ├── admin.service.ts │ │ │ ├── admin.service.spec.ts │ │ │ ├── admin.controller.spec.ts │ │ │ └── admin.controller.ts │ │ ├── sse │ │ │ ├── sse.service.spec.ts │ │ │ └── sse.service.ts │ │ ├── game │ │ │ ├── game.service.spec.ts │ │ │ ├── game.controller.spec.ts │ │ │ ├── game.module.ts │ │ │ ├── game.sse.service.ts │ │ │ ├── game.service.ts │ │ │ └── game.controller.ts │ │ ├── players │ │ │ ├── players.module.ts │ │ │ ├── players.controller.ts │ │ │ ├── players.service.spec.ts │ │ │ └── players.service.ts │ │ ├── export │ │ │ ├── export.controller.spec.ts │ │ │ └── export.controller.ts │ │ ├── waiting-room │ │ │ ├── waiting-room.controller.spec.ts │ │ │ ├── waiting-room.controller.ts │ │ │ └── waiting-room.sse.service.ts │ │ ├── app.controller.spec.ts │ │ ├── app.controller.ts │ │ ├── app.service.ts │ │ └── app.module.ts │ ├── test │ │ ├── jest-e2e.json │ │ └── app.e2e-spec.ts │ ├── tsconfig.json │ ├── package.json │ └── README.md ├── frontend │ ├── next-env.d.ts │ ├── public │ │ ├── favicon.ico │ │ ├── player-icon-blue.svg │ │ ├── player-icon-red.svg │ │ ├── player-icon-green.svg │ │ ├── player-icon-yellow.svg │ │ ├── players.svg │ │ ├── help-icon.svg │ │ ├── points.svg │ │ ├── slider.svg │ │ ├── pointsTaken.svg │ │ └── pointsReplenished.svg │ ├── constants │ │ ├── colorConstants.ts │ │ └── gameConstants.ts │ ├── styles │ │ ├── constants.scss │ │ ├── ThankYou.module.scss │ │ ├── EndText.module.scss │ │ ├── timer.module.scss │ │ ├── GameModal.module.scss │ │ ├── globals.scss │ │ ├── GameInstructions.module.scss │ │ ├── Home.module.scss │ │ ├── WaitingRoom.module.scss │ │ ├── Admin.module.scss │ │ └── Game.module.scss │ ├── pages │ │ ├── _app.tsx │ │ ├── thank-you.tsx │ │ ├── index.tsx │ │ ├── waiting-room.tsx │ │ ├── game-instructions.tsx │ │ ├── practice-game.tsx │ │ ├── admin.tsx │ │ └── game.tsx │ ├── hooks │ │ ├── useSetting.ts │ │ └── useEventSource.ts │ ├── components │ │ ├── endingText.tsx │ │ ├── gameModal.tsx │ │ └── timer.tsx │ ├── tsconfig.json │ ├── package.json │ ├── README.md │ └── api-client │ │ └── index.ts ├── index.ts └── scripts │ └── create-database.sh ├── nodemon.json ├── tsconfig.json ├── .gitignore ├── .eslintrc.js └── package.json /README.md: -------------------------------------------------------------------------------- 1 | # gratitudenu -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all" 4 | } 5 | -------------------------------------------------------------------------------- /src/backend/nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "collection": "@nestjs/schematics", 3 | "sourceRoot": "src" 4 | } 5 | -------------------------------------------------------------------------------- /src/frontend/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | function helloWorld(): void { 2 | console.log('Hello World!'); 3 | } 4 | 5 | helloWorld(); 6 | -------------------------------------------------------------------------------- /src/frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sandboxnu/gratitudenu/main/src/frontend/public/favicon.ico -------------------------------------------------------------------------------- /src/frontend/constants/colorConstants.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | darkPurple: '#546ec9', 3 | darkBlue: '#002a52', 4 | }; 5 | -------------------------------------------------------------------------------- /src/backend/.env.dev: -------------------------------------------------------------------------------- 1 | DB_URL=postgres://postgres:mysecretpassword@localhost:5432/my_database 2 | EXPORT_PASSWORD=thankyou 3 | DELETE_PASSWORD=shame -------------------------------------------------------------------------------- /src/backend/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /src/backend/src/round/round.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller } from '@nestjs/common'; 2 | 3 | @Controller('round') 4 | export class RoundController {} 5 | -------------------------------------------------------------------------------- /src/frontend/styles/constants.scss: -------------------------------------------------------------------------------- 1 | // Colors 2 | $dark-blue: #002a52; 3 | $dark-purple: #546ec9; 4 | $error: #ff0033; 5 | 6 | //Fonts 7 | $font-size-header: 40px; 8 | $font-size-body: 26px; 9 | -------------------------------------------------------------------------------- /src/frontend/constants/gameConstants.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | MIN_TAKE_VAL: 0, 3 | MAX_TAKE_VAL: 10, 4 | INIT_TIME_LEFT: 10, 5 | DEFAULT_COLOR: 'Green', 6 | INIT_PLAYER_COINS: 0, 7 | INIT_TOTAL_COINS: 200, 8 | }; 9 | -------------------------------------------------------------------------------- /src/backend/test/jest-e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "moduleFileExtensions": ["js", "json", "ts"], 3 | "rootDir": ".", 4 | "testEnvironment": "node", 5 | "testRegex": ".e2e-spec.ts$", 6 | "transform": { 7 | "^.+\\.(t|j)s$": "ts-jest" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/frontend/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import '../styles/globals.scss'; 2 | import type { AppProps } from 'next/app'; 3 | import React from 'react'; 4 | 5 | function MyApp({ Component, pageProps }: AppProps) { 6 | return ; 7 | } 8 | 9 | export default MyApp; 10 | -------------------------------------------------------------------------------- /src/frontend/styles/ThankYou.module.scss: -------------------------------------------------------------------------------- 1 | @import 'constants'; 2 | 3 | .messageContainer { 4 | margin: 150px auto; 5 | max-width: 600px; 6 | } 7 | 8 | .timeOutMessage { 9 | text-align: center; 10 | font-size: 40px; 11 | color: $dark-blue; 12 | margin: 20px 0; 13 | } 14 | -------------------------------------------------------------------------------- /src/backend/src/main.ts: -------------------------------------------------------------------------------- 1 | import { NestFactory } from '@nestjs/core'; 2 | import { AppModule } from './app.module'; 3 | 4 | async function bootstrap() { 5 | const app = await NestFactory.create(AppModule); 6 | app.enableCors(); 7 | await app.listen(3001); 8 | } 9 | bootstrap(); 10 | -------------------------------------------------------------------------------- /nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "restartable": "rs", 3 | "ignore": [".git", "node_modules/", "dist/", "coverage/"], 4 | "watch": ["src/"], 5 | "execMap": { 6 | "ts": "node -r ts-node/register" 7 | }, 8 | "env": { 9 | "NODE_ENV": "development" 10 | }, 11 | "ext": "js,json,ts" 12 | } 13 | -------------------------------------------------------------------------------- /src/backend/src/entities/setting.entity.ts: -------------------------------------------------------------------------------- 1 | import { Entity, Column, BaseEntity, PrimaryColumn, IsNull } from 'typeorm'; 2 | 3 | @Entity() 4 | export class Setting extends BaseEntity { 5 | @PrimaryColumn() 6 | settingName: string; 7 | 8 | @Column({ nullable: true }) 9 | value: number; 10 | } 11 | -------------------------------------------------------------------------------- /src/frontend/styles/EndText.module.scss: -------------------------------------------------------------------------------- 1 | @import 'constants'; 2 | 3 | .endTextContainer { 4 | color: $dark-blue; 5 | font-size: 30px; 6 | text-align: center; 7 | margin: auto; 8 | max-width: 500px; 9 | } 10 | 11 | .gameOver { 12 | font-size: 40px; 13 | padding: 2rem; 14 | font-weight: bold; 15 | } 16 | -------------------------------------------------------------------------------- /src/frontend/styles/timer.module.scss: -------------------------------------------------------------------------------- 1 | @import 'constants'; 2 | .timer { 3 | background: #f6f6f6; 4 | box-shadow: 0 4px 4px rgba(0, 0, 0, 0.25); 5 | border-radius: 6px; 6 | text-align: center; 7 | font-family: Roboto, sans-serif; 8 | font-weight: 500; 9 | color: $dark-blue; 10 | width: 440px; 11 | height: 160px; 12 | font-size: 130px; 13 | } 14 | -------------------------------------------------------------------------------- /src/frontend/hooks/useSetting.ts: -------------------------------------------------------------------------------- 1 | import useSWR from 'swr'; 2 | import { API } from '../api-client'; 3 | 4 | type Hook = (settingName: string) => number; 5 | 6 | export const useSetting: Hook = (settingName: string) => { 7 | const { data } = useSWR(`/admin?settingName=${settingName}`, async () => 8 | API.settings.get(settingName), 9 | ); 10 | 11 | return data?.valueOf(); 12 | }; 13 | -------------------------------------------------------------------------------- /src/backend/src/admin/admin.service.ts: -------------------------------------------------------------------------------- 1 | import { BadRequestException, Injectable } from '@nestjs/common'; 2 | 3 | @Injectable() 4 | export class AdminService { 5 | verifyPassword(password: string): void { 6 | const realPassword = process.env.EXPORT_PASSWORD; 7 | 8 | if (realPassword !== password) { 9 | throw new BadRequestException('Incorrect Password'); 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/backend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": true, 5 | "removeComments": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "allowSyntheticDefaultImports": true, 9 | "target": "es2017", 10 | "sourceMap": true, 11 | "outDir": "./dist", 12 | "baseUrl": "./", 13 | "incremental": true 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/frontend/pages/thank-you.tsx: -------------------------------------------------------------------------------- 1 | import { ReactElement } from 'react'; 2 | import styles from '../styles/ThankYou.module.scss'; 3 | 4 | export default function TimeOut(): ReactElement { 5 | return ( 6 |
7 |
8 | Unfortunately, we were unable to find other players to begin the game at 9 | this time. 10 |
11 |
12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /src/frontend/public/player-icon-blue.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/frontend/public/player-icon-red.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/frontend/public/player-icon-green.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/frontend/public/player-icon-yellow.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/frontend/hooks/useEventSource.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | 3 | export const useEventSource = ( 4 | url: string, 5 | onmessage: (d: any) => void, 6 | ): any => { 7 | useEffect(() => { 8 | if (url) { 9 | const source = new EventSource(url); 10 | 11 | source.onmessage = function logEvents(event) { 12 | onmessage(JSON.parse(event.data)); 13 | }; 14 | 15 | return () => source.close(); 16 | } 17 | }, [url]); 18 | }; 19 | -------------------------------------------------------------------------------- /src/frontend/components/endingText.tsx: -------------------------------------------------------------------------------- 1 | import { ReactElement } from 'react'; 2 | import styles from '../styles/EndText.module.scss'; 3 | 4 | export default function EndText(): ReactElement { 5 | return ( 6 |
7 |
GAME OVER
8 |
9 | Thank you for completing the game! Please exit out of this window and 10 | return to the Qualtrics survey. 11 |
12 |
13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": true, 5 | "removeComments": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "allowSyntheticDefaultImports": true, 9 | "target": "es2017", 10 | "sourceMap": true, 11 | "outDir": "./dist", 12 | "baseUrl": "./", 13 | "incremental": true 14 | "jsx": "react", 15 | "esModuleInterop": true 16 | }, 17 | 18 | "exclude": ["**/node_modules", "dist"] 19 | } 20 | -------------------------------------------------------------------------------- /src/backend/src/sse/sse.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { SseService } from './sse.service'; 3 | 4 | describe('SseService', () => { 5 | let service: SseService; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | providers: [SseService], 10 | }).compile(); 11 | 12 | service = module.get(SseService); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(service).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/backend/src/game/game.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { GameService } from './game.service'; 3 | 4 | describe('GameService', () => { 5 | let service: GameService; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | providers: [GameService], 10 | }).compile(); 11 | 12 | service = module.get(GameService); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(service).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/frontend/styles/GameModal.module.scss: -------------------------------------------------------------------------------- 1 | @import 'constants'; 2 | 3 | .gameModal { 4 | position: relative; 5 | width: 350px; 6 | height: 230px; 7 | font-size: 40px; 8 | color: $dark-blue; 9 | text-align: center; 10 | top: 50%; 11 | left: 50%; 12 | right: auto; 13 | bottom: auto; 14 | margin-right: -50%; 15 | transform: translate(-50%, -50%); 16 | background: #f6f6f6; 17 | box-shadow: 0 8px 8px rgba(0, 0, 0, 0.35); 18 | overflow: auto; 19 | border-radius: 8px; 20 | padding: 70px 10px 0 10px; 21 | font-weight: 600; 22 | } 23 | -------------------------------------------------------------------------------- /src/backend/src/admin/admin.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { AdminService } from './admin.service'; 3 | 4 | describe('AdminService', () => { 5 | let service: AdminService; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | providers: [AdminService], 10 | }).compile(); 11 | 12 | service = module.get(AdminService); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(service).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/backend/src/round/round.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { RoundService } from './round.service'; 3 | 4 | describe('RoundService', () => { 5 | let service: RoundService; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | providers: [RoundService], 10 | }).compile(); 11 | 12 | service = module.get(RoundService); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(service).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/backend/src/players/players.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { TypeOrmModule } from '@nestjs/typeorm'; 3 | import { Player } from '../entities/player.entity'; 4 | import { PlayersService } from './players.service'; 5 | import { PlayersController } from './players.controller'; 6 | import { Game } from '../entities/game.entity'; 7 | 8 | @Module({ 9 | imports: [TypeOrmModule.forFeature([Player, Game])], 10 | providers: [PlayersService], 11 | controllers: [PlayersController], 12 | exports: [TypeOrmModule], 13 | }) 14 | export class PlayersModule {} 15 | -------------------------------------------------------------------------------- /src/frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": false, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve" 16 | }, 17 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], 18 | "exclude": ["node_modules"] 19 | } 20 | -------------------------------------------------------------------------------- /src/backend/src/game/game.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { GameController } from './game.controller'; 3 | 4 | describe('GameController', () => { 5 | let controller: GameController; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | controllers: [GameController], 10 | }).compile(); 11 | 12 | controller = module.get(GameController); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(controller).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/backend/src/admin/admin.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { AdminController } from './admin.controller'; 3 | 4 | describe('AdminController', () => { 5 | let controller: AdminController; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | controllers: [AdminController], 10 | }).compile(); 11 | 12 | controller = module.get(AdminController); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(controller).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/backend/src/round/round.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { RoundController } from './round.controller'; 3 | 4 | describe('RoundController', () => { 5 | let controller: RoundController; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | controllers: [RoundController], 10 | }).compile(); 11 | 12 | controller = module.get(RoundController); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(controller).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/backend/src/export/export.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { ExportController } from './export.controller'; 3 | 4 | describe('ExportController', () => { 5 | let controller: ExportController; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | controllers: [ExportController], 10 | }).compile(); 11 | 12 | controller = module.get(ExportController); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(controller).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/backend/src/waiting-room/waiting-room.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { WaitingRoomController } from './waiting-room.controller'; 3 | 4 | describe('WaitingRoomController', () => { 5 | let controller: WaitingRoomController; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | controllers: [WaitingRoomController], 10 | }).compile(); 11 | 12 | controller = module.get(WaitingRoomController); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(controller).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/frontend/components/gameModal.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement } from 'react'; 2 | import Modal from 'react-modal'; 3 | import EndingText from './endingText'; 4 | import styles from '../styles/GameModal.module.scss'; 5 | 6 | type GameModalProps = { 7 | isOpen: boolean; 8 | text: string | ReactElement; 9 | }; 10 | 11 | export default function GameModal({ 12 | isOpen, 13 | text, 14 | }: GameModalProps): ReactElement { 15 | return ( 16 | 21 | {text == 'Game over' ? : text} 22 | 23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /src/backend/src/app.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { AppController } from './app.controller'; 3 | import { AppService } from './app.service'; 4 | 5 | describe('AppController', () => { 6 | let appController: AppController; 7 | 8 | beforeEach(async () => { 9 | const app: TestingModule = await Test.createTestingModule({ 10 | controllers: [AppController], 11 | providers: [AppService], 12 | }).compile(); 13 | 14 | appController = app.get(AppController); 15 | }); 16 | 17 | // describe('root', () => { 18 | // it('should return "Hello World!"', () => {}); 19 | // }); 20 | }); 21 | -------------------------------------------------------------------------------- /src/backend/src/entities/round.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Entity, 3 | Column, 4 | PrimaryGeneratedColumn, 5 | OneToMany, 6 | ManyToOne, 7 | PrimaryColumn, 8 | BaseEntity, 9 | } from 'typeorm'; 10 | import { Game } from './game.entity'; 11 | import { Grab } from './grab.entity'; 12 | 13 | @Entity() 14 | export class Round extends BaseEntity { 15 | @PrimaryGeneratedColumn() 16 | id: number; 17 | 18 | @Column() 19 | roundNumber: number; 20 | 21 | @Column() 22 | pointsRemaining: number; 23 | 24 | @OneToMany((type) => Grab, (grab) => grab.round) 25 | playerMoves: Grab[]; 26 | 27 | @ManyToOne((type) => Game, (game) => game.rounds) 28 | game: Game; 29 | } 30 | -------------------------------------------------------------------------------- /src/scripts/create-database.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | SERVER="my_database_server"; 5 | PW="mysecretpassword"; 6 | DB="my_database"; 7 | 8 | echo "echo stop & remove old docker [$SERVER] and starting new fresh instance of [$SERVER]" 9 | (docker kill $SERVER || :) && \ 10 | (docker rm $SERVER || :) && \ 11 | docker run --name $SERVER -e POSTGRES_PASSWORD=$PW \ 12 | -e PGPASSWORD=$PW \ 13 | -p 5432:5432 \ 14 | -d postgres 15 | 16 | # wait for pg to start 17 | echo "sleep wait for pg-server [$SERVER] to start"; 18 | SLEEP 3; 19 | 20 | # create the db 21 | echo "CREATE DATABASE $DB ENCODING 'UTF-8';" | docker exec -i $SERVER psql -U postgres 22 | echo "\l" | docker exec -i $SERVER psql -U postgres -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist 3 | /src/backend/dist 4 | node_modules/ 5 | 6 | # testing 7 | src/frontend/coverage 8 | 9 | # next.js 10 | src/frontend/.next 11 | src/frontend/out/ 12 | src/frontend/build 13 | src/frontend/.vercel 14 | 15 | # Logs 16 | logs 17 | *.log 18 | npm-debug.log* 19 | yarn-debug.log* 20 | yarn-error.log* 21 | lerna-debug.log* 22 | 23 | # OS 24 | .DS_Store 25 | 26 | # Tests 27 | /coverage 28 | /.nyc_output 29 | 30 | # IDEs and editors 31 | /.idea 32 | .project 33 | .classpath 34 | .c9/ 35 | *.launch 36 | .settings/ 37 | *.sublime-workspace 38 | 39 | # IDE - VSCode 40 | .vscode/* 41 | !.vscode/settings.json 42 | !.vscode/tasks.json 43 | !.vscode/launch.json 44 | !.vscode/extensions.json 45 | -------------------------------------------------------------------------------- /src/backend/test/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { INestApplication } from '@nestjs/common'; 3 | import * as request from 'supertest'; 4 | import { AppModule } from './../src/app.module'; 5 | 6 | describe('AppController (e2e)', () => { 7 | let app: INestApplication; 8 | 9 | beforeEach(async () => { 10 | const moduleFixture: TestingModule = await Test.createTestingModule({ 11 | imports: [AppModule], 12 | }).compile(); 13 | 14 | app = moduleFixture.createNestApplication(); 15 | await app.init(); 16 | }); 17 | 18 | it('/ (GET)', () => { 19 | return request(app.getHttpServer()) 20 | .get('/') 21 | .expect(200) 22 | .expect('Hello World!'); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | parserOptions: { 4 | project: ['./packages/*/tsconfig.json'], 5 | sourceType: 'module', 6 | }, 7 | plugins: ['@typescript-eslint/eslint-plugin'], 8 | extends: [ 9 | 'plugin:@typescript-eslint/eslint-recommended', 10 | 'plugin:@typescript-eslint/recommended', 11 | 'prettier', 12 | 'prettier/@typescript-eslint', 13 | ], 14 | ignorePatterns: ['.eslintrc.js'], 15 | root: true, 16 | env: { 17 | node: true, 18 | jest: true, 19 | }, 20 | rules: { 21 | '@typescript-eslint/interface-name-prefix': 'off', 22 | '@typescript-eslint/explicit-function-return-type': 'off', 23 | '@typescript-eslint/no-explicit-any': 'off', 24 | }, 25 | }; 26 | -------------------------------------------------------------------------------- /src/backend/src/entities/player.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Entity, 3 | Column, 4 | PrimaryGeneratedColumn, 5 | ManyToOne, 6 | BaseEntity, 7 | OneToMany, 8 | } from 'typeorm'; 9 | import { Game } from './game.entity'; 10 | import { Grab } from './grab.entity'; 11 | 12 | export enum EmotionIdEnum { 13 | Greedy = 1, 14 | NotGreedy = 2, 15 | } 16 | 17 | @Entity() 18 | export class Player extends BaseEntity { 19 | @PrimaryGeneratedColumn() 20 | id: number; 21 | 22 | @Column() 23 | userId: number; 24 | 25 | @Column() 26 | emotionId: EmotionIdEnum; 27 | 28 | @ManyToOne((type) => Game, (game) => game.players) 29 | game: Game; 30 | 31 | @OneToMany((type) => Grab, (grab) => grab.player) 32 | grabs: Grab[]; 33 | 34 | @Column({ 35 | nullable: true, 36 | }) 37 | color!: string; 38 | } 39 | -------------------------------------------------------------------------------- /src/frontend/styles/globals.scss: -------------------------------------------------------------------------------- 1 | @import 'constants'; 2 | 3 | html, 4 | body { 5 | padding: 0; 6 | margin: 0; 7 | font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, 8 | Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif; 9 | } 10 | 11 | a { 12 | color: inherit; 13 | text-decoration: none; 14 | } 15 | 16 | * { 17 | box-sizing: border-box; 18 | } 19 | 20 | .primaryButton { 21 | background: $dark-purple; 22 | box-shadow: 0 4px 4px rgba(0, 0, 0, 0.25); 23 | border-radius: 10px; 24 | color: white; 25 | border: none; 26 | font-size: 22px; 27 | font-weight: bold; 28 | padding: 16px 22px; 29 | position: relative; 30 | 31 | &:hover { 32 | opacity: 0.8; 33 | cursor: pointer; 34 | } 35 | 36 | &:active { 37 | bottom: -2px; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/frontend/styles/GameInstructions.module.scss: -------------------------------------------------------------------------------- 1 | @import 'constants'; 2 | 3 | .instructionsHeader { 4 | color: $dark-blue; 5 | font-weight: bold; 6 | font-size: $font-size-header; 7 | text-align: center; 8 | margin-top: 50px; 9 | padding-bottom: 4rem; 10 | } 11 | 12 | .gameInstructionsContainer { 13 | margin: 4rem 17rem; 14 | } 15 | 16 | .gameInstruction { 17 | color: $dark-blue; 18 | font-weight: 300; 19 | font-size: $font-size-body; 20 | padding-bottom: 1rem; 21 | line-height: 35px; 22 | } 23 | 24 | .gameExample { 25 | text-align: center; 26 | } 27 | 28 | .gameExampleText { 29 | color: $dark-blue; 30 | font-weight: 300; 31 | font-style: italic; 32 | font-size: $font-size-body; 33 | line-height: 35px; 34 | } 35 | 36 | .buttonContainer { 37 | text-align: right; 38 | padding-top: 1rem; 39 | } 40 | -------------------------------------------------------------------------------- /src/backend/src/players/players.controller.ts: -------------------------------------------------------------------------------- 1 | import { BadRequestException, Body, Controller, Post } from '@nestjs/common'; 2 | import { Player, EmotionIdEnum } from '../entities/player.entity'; 3 | 4 | @Controller('players') 5 | export class PlayersController { 6 | @Post() 7 | async create( 8 | @Body('userId') userId: number, 9 | @Body('emotionId') emotionId: number, 10 | ): Promise { 11 | if (!userId || !emotionId) { 12 | throw new BadRequestException( 13 | 'Create Player requires emotionId and userId', 14 | ); 15 | } 16 | if (!Object.values(EmotionIdEnum).includes(emotionId)) { 17 | throw new BadRequestException('EmotionId is invalid'); 18 | } 19 | const player = await Player.create({ 20 | userId, 21 | emotionId, 22 | }).save(); 23 | 24 | return player.id; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/backend/src/entities/grab.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Entity, 3 | Column, 4 | PrimaryGeneratedColumn, 5 | OneToOne, 6 | JoinColumn, 7 | PrimaryColumn, 8 | ManyToOne, 9 | BaseEntity, 10 | } from 'typeorm'; 11 | import { Round } from './round.entity'; 12 | import { Player } from './player.entity'; 13 | 14 | @Entity() 15 | export class Grab extends BaseEntity { 16 | @PrimaryGeneratedColumn() 17 | id: number; 18 | 19 | @ManyToOne((type) => Player, (player) => player.grabs) 20 | @JoinColumn() 21 | player: Player; 22 | 23 | /** 24 | * How Many Points were taken in this grab 25 | */ 26 | @Column() 27 | howMany: number; 28 | 29 | /** 30 | * How long did it take the user to take this many points 31 | */ 32 | @Column() 33 | timeTaken: number; 34 | 35 | @ManyToOne((type) => Round, (round) => round.playerMoves) 36 | round: Round; 37 | } 38 | -------------------------------------------------------------------------------- /src/frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start" 9 | }, 10 | "dependencies": { 11 | "@types/react-modal": "^3.12.0", 12 | "axios": "^0.21.1", 13 | "class-transformer": "0.3.1", 14 | "next": "^10.0.6", 15 | "react": "^17.0.1", 16 | "react-circular-progressbar": "^2.0.3", 17 | "react-csv": "^2.0.3", 18 | "react-dom": "^17.0.1", 19 | "react-input-slider": "^6.0.0", 20 | "react-modal": "^3.12.1", 21 | "react-spring": "^9.0.0", 22 | "swr": "^0.5.4" 23 | }, 24 | "devDependencies": { 25 | "@types/node": "^14.14.27", 26 | "@types/node-sass": "^4.11.1", 27 | "@types/react": "^17.0.2", 28 | "node-sass": "^5.0.0", 29 | "typescript": "^4.1.5" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/backend/src/entities/game.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Entity, 3 | Column, 4 | PrimaryGeneratedColumn, 5 | OneToMany, 6 | BaseEntity, 7 | } from 'typeorm'; 8 | import { Player } from './player.entity'; 9 | import { Round } from './round.entity'; 10 | 11 | @Entity() 12 | export class Game extends BaseEntity { 13 | @PrimaryGeneratedColumn() 14 | id: number; 15 | 16 | @OneToMany((type) => Round, (round) => round.game) 17 | rounds: Round[]; 18 | 19 | @Column() 20 | ongoing: boolean; 21 | 22 | @OneToMany((type) => Player, (player) => player.game) 23 | players: Player[]; 24 | 25 | @Column({ nullable: true }) // has to be nullable for backwards compatibility but i guess if you're willing to tear down your database then you can? 26 | maxRounds: number; // Including max rounds on game, so that it doesn't become weird and inconsistent between games if changed in the middle 27 | } 28 | -------------------------------------------------------------------------------- /src/frontend/styles/Home.module.scss: -------------------------------------------------------------------------------- 1 | @import 'constants'; 2 | 3 | .container { 4 | text-align: center; 5 | } 6 | .title { 7 | margin: 100px 0 40px 0; 8 | font-weight: bold; 9 | font-size: $font-size-header; 10 | color: $dark-blue; 11 | } 12 | 13 | .form { 14 | display: flex; 15 | justify-content: center; 16 | } 17 | 18 | .formInputs { 19 | text-align: left; 20 | font-weight: 500; 21 | font-size: 20px; 22 | color: $dark-blue; 23 | display: flex; 24 | flex-direction: column; 25 | justify-content: center; 26 | align-items: center; 27 | 28 | .formInput { 29 | width: fit-content; 30 | } 31 | 32 | & input { 33 | /* white */ 34 | height: 59px; 35 | font-size: 16px; 36 | width: 306px; 37 | padding-left: 10px; 38 | margin: 4px 0 16px; 39 | background: #ffffff; 40 | border: 2px solid rgba(0, 42, 82, 0.5); 41 | box-sizing: border-box; 42 | border-radius: 4px; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/backend/src/game/game.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { TypeOrmModule } from '@nestjs/typeorm'; 3 | import { Player } from '../entities/player.entity'; 4 | import { Game } from '../entities/game.entity'; 5 | import { GameService } from './game.service'; 6 | import { GameController } from './game.controller'; 7 | import { Round } from '../entities/round.entity'; 8 | import { GameSseService } from './game.sse.service'; 9 | import { Grab } from '../entities/grab.entity'; 10 | import { PlayersService } from '../players/players.service'; 11 | import { RoundService } from '../round/round.service'; 12 | import { SSEService } from '../sse/sse.service'; 13 | 14 | @Module({ 15 | imports: [TypeOrmModule.forFeature([Player, Game, Round, Grab])], 16 | providers: [ 17 | GameService, 18 | GameSseService, 19 | PlayersService, 20 | RoundService, 21 | SSEService, 22 | ], 23 | controllers: [GameController], 24 | exports: [TypeOrmModule], 25 | }) 26 | export class GameModule {} 27 | -------------------------------------------------------------------------------- /src/backend/src/waiting-room/waiting-room.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get, Query, Res } from '@nestjs/common'; 2 | import { Response } from 'express'; 3 | import { PlayersService } from 'src/players/players.service'; 4 | import { WaitingRoomSSEService } from './waiting-room.sse.service'; 5 | 6 | @Controller('waiting-room') 7 | export class WaitingRoomController { 8 | constructor( 9 | private playerService: PlayersService, 10 | private waitingRoomSseService: WaitingRoomSSEService, 11 | ) {} 12 | 13 | @Get() 14 | async addPlayerToRoom( 15 | @Query('playerId') playerId: number, 16 | @Res() res: Response, 17 | ): Promise { 18 | const player = await this.playerService.findOne(playerId); 19 | res.set({ 20 | 'Content-Type': 'text/event-stream', 21 | 'Cache-Control': 'no-cache', 22 | 'X-Accel-Buffering': 'no', 23 | Connection: 'keep-alive', 24 | }); 25 | 26 | await this.waitingRoomSseService.subscribeClient(res, { 27 | playerId, 28 | emotionId: player.emotionId, 29 | }); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/frontend/components/timer.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement, useEffect, useState } from 'react'; 2 | import styles from '../styles/timer.module.scss'; 3 | 4 | type TimerProps = { 5 | time: number; 6 | onTimerOver?: () => void; 7 | shouldResetTimer?: boolean; 8 | customClass?: string; 9 | formatTime?: (time: number) => string; 10 | }; 11 | 12 | export default function Timer({ 13 | time, 14 | shouldResetTimer, 15 | customClass = '', 16 | formatTime = (time) => time.toString(), 17 | onTimerOver = () => '', 18 | }: TimerProps): ReactElement { 19 | const [timeLeft, setTimeLeft] = useState(time); 20 | useEffect(() => { 21 | const interval = setInterval( 22 | () => setTimeLeft((timeLeft) => (timeLeft === 0 ? 0 : timeLeft - 1)), 23 | 1000, 24 | ); 25 | 26 | return () => clearInterval(interval); 27 | }, [timeLeft]); 28 | 29 | if (timeLeft === 0) { 30 | onTimerOver(); 31 | if (shouldResetTimer) { 32 | setTimeLeft(time); 33 | } 34 | } 35 | 36 | return
{formatTime(timeLeft)}
; 37 | } 38 | -------------------------------------------------------------------------------- /src/frontend/styles/WaitingRoom.module.scss: -------------------------------------------------------------------------------- 1 | @import 'constants'; 2 | 3 | .waitingRoom { 4 | color: $dark-blue; 5 | } 6 | 7 | .headerSection { 8 | text-align: center; 9 | display: flex; 10 | flex-direction: column; 11 | justify-content: center; 12 | align-items: center; 13 | } 14 | 15 | .header { 16 | font-weight: 500; 17 | font-size: 50px; 18 | padding: 104px 0 50px; 19 | } 20 | 21 | .bottomSection { 22 | text-align: center; 23 | display: flex; 24 | justify-content: space-evenly; 25 | } 26 | 27 | .gameInstructions { 28 | padding-right: 60px; 29 | padding-top: 77px; 30 | text-align: left; 31 | width: 585px; 32 | } 33 | 34 | .gameInstructionsHeader { 35 | font-weight: bold; 36 | font-size: 35px; 37 | } 38 | 39 | .gameInstructionsList { 40 | font-weight: 600; 41 | font-size: 22px; 42 | } 43 | 44 | .playerCountSection { 45 | padding-top: 153px; 46 | } 47 | 48 | .playerCountHeader { 49 | font-weight: bold; 50 | font-size: 30px; 51 | width: 359px; 52 | } 53 | 54 | .playerCountFraction { 55 | font-weight: bold; 56 | font-size: 50px; 57 | } 58 | -------------------------------------------------------------------------------- /src/backend/src/app.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BadRequestException, 3 | Body, 4 | Controller, 5 | Delete, 6 | Get, 7 | } from '@nestjs/common'; 8 | import { AppService } from './app.service'; 9 | import { Game } from './entities/game.entity'; 10 | import { Grab } from './entities/grab.entity'; 11 | import { Player } from './entities/player.entity'; 12 | import { Round } from './entities/round.entity'; 13 | import { Setting } from './entities/setting.entity'; 14 | 15 | @Controller() 16 | export class AppController { 17 | constructor(private readonly appService: AppService) {} 18 | 19 | @Get() 20 | getHello(): string { 21 | // return this.appService.getHello(); 22 | return 'hi'; 23 | } 24 | 25 | @Delete('db') 26 | async tearDownDb(@Body('password') password: string): Promise { 27 | if (process.env.DELETE_PASSWORD !== password) { 28 | throw new BadRequestException('Password is not correct'); 29 | } 30 | 31 | await Grab.delete({}); 32 | await Round.delete({}); 33 | await Player.delete({}); 34 | await Game.delete({}); 35 | 36 | return true; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/frontend/styles/Admin.module.scss: -------------------------------------------------------------------------------- 1 | @import 'constants'; 2 | 3 | .pageContainer { 4 | margin-top: 4rem; 5 | } 6 | 7 | .export { 8 | display: flex; 9 | justify-content: center; 10 | align-items: center; 11 | flex-direction: column; 12 | padding-top: 35px; 13 | } 14 | 15 | .formInput { 16 | width: fit-content; 17 | font-weight: 500; 18 | font-size: 20px; 19 | color: $dark-blue; 20 | } 21 | 22 | .form { 23 | display: flex; 24 | justify-content: space-between; 25 | padding-bottom: 10px; 26 | 27 | & input { 28 | /* white */ 29 | height: 59px; 30 | width: 300px; 31 | margin: 4px 0 16px; 32 | font-size: 18px; 33 | padding-left: 12px; 34 | background: #ffffff; 35 | border: 2px solid rgba(0, 42, 82, 0.5); 36 | box-sizing: border-box; 37 | border-radius: 4px; 38 | } 39 | } 40 | 41 | .button { 42 | display: flex; 43 | justify-content: center; 44 | align-items: center; 45 | } 46 | 47 | .saveButton { 48 | height: 59px; 49 | margin: 5px 0 0 30px; 50 | } 51 | 52 | .exportButton { 53 | margin-top: 2rem; 54 | } 55 | 56 | .error { 57 | color: $error; 58 | text-align: center; 59 | padding-top: 5px; 60 | } 61 | -------------------------------------------------------------------------------- /src/backend/src/players/players.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { PlayersService } from './players.service'; 2 | import { Test, TestingModule } from '@nestjs/testing'; 3 | import { PlayersModule } from './players.module'; 4 | import { TypeOrmModule } from '@nestjs/typeorm'; 5 | import { Grab } from '../entities/grab.entity'; 6 | import { Player } from '../entities/player.entity'; 7 | import { Round } from '../entities/round.entity'; 8 | import { Game } from '../entities/game.entity'; 9 | 10 | export const TestTypeOrmModule = TypeOrmModule.forRoot({ 11 | type: 'postgres', 12 | host: 'localhost', 13 | port: 5432, 14 | username: 'postgres', 15 | password: 'mysecretpassword', 16 | database: 'my_database', 17 | entities: [Grab, Player, Round, Game], 18 | synchronize: true, 19 | }); 20 | 21 | describe('PlayersService', () => { 22 | let service: PlayersService; 23 | 24 | beforeEach(async () => { 25 | const module: TestingModule = await Test.createTestingModule({ 26 | imports: [PlayersModule, TestTypeOrmModule], 27 | providers: [PlayersService], 28 | }).compile(); 29 | 30 | service = module.get(PlayersService); 31 | }); 32 | 33 | it('should be defined', () => { 34 | expect(service).toBeDefined(); 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /src/backend/src/game/game.sse.service.ts: -------------------------------------------------------------------------------- 1 | import { Client, SSEService } from '../sse/sse.service'; 2 | import { Response } from 'express'; 3 | import { GameService } from './game.service'; 4 | import { Round } from '../entities/round.entity'; 5 | import { RoundService } from '../round/round.service'; 6 | import { Injectable } from '@nestjs/common'; 7 | 8 | type GameClientMetadata = { playerId: number; gameId: number }; 9 | 10 | @Injectable() 11 | export class GameSseService { 12 | private clients: Record[]> = {}; 13 | constructor( 14 | private gameService: GameService, 15 | private roundService: RoundService, 16 | private sseService: SSEService, 17 | ) {} 18 | 19 | /** 20 | * Subscribe a Client to the Game 21 | * @param res 22 | * @param metadata 23 | */ 24 | async subscribeClient( 25 | res: Response, 26 | metadata: GameClientMetadata, 27 | ): Promise { 28 | this.sseService.subscribeClient(metadata.gameId, { res, metadata }); 29 | } 30 | 31 | endGame(gameId: number) { 32 | this.sseService.sendEvent(gameId, { 33 | endMessage: 'The game is over', 34 | }); 35 | this.sseService.unsubscribeRoom(gameId); 36 | } 37 | 38 | updateGameWithRoundResults(gameId: number, newRound: Round): void { 39 | this.sseService.sendEvent(gameId, { newRound }); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/frontend/public/players.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /src/backend/src/admin/admin.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BadRequestException, 3 | Body, 4 | Controller, 5 | Get, 6 | Patch, 7 | Query, 8 | } from '@nestjs/common'; 9 | import { Setting } from 'src/entities/setting.entity'; 10 | import { AdminService } from './admin.service'; 11 | 12 | @Controller('admin') 13 | export class AdminController { 14 | constructor(private adminService: AdminService) {} 15 | 16 | @Get() 17 | async getSetting( 18 | @Query('settingName') settingName: string, 19 | ): Promise { 20 | const setting = await Setting.findOne(settingName); 21 | 22 | return setting ? setting.value : null; 23 | } 24 | 25 | @Patch('setting') 26 | async updateSetting( 27 | @Body('settingName') settingName: string, 28 | @Body('value') value: number, 29 | @Body('password') password: string, 30 | ): Promise { 31 | this.adminService.verifyPassword(password); 32 | 33 | const setting = await Setting.findOne(settingName); 34 | if (!setting) { 35 | throw new BadRequestException('This Setting does not exist'); 36 | } 37 | 38 | if (settingName === 'ROUND_TIMER' && (value < 0 || value > 25)) { 39 | throw new BadRequestException( 40 | 'Round timer must be within range 0 - 25 seconds', 41 | ); 42 | } 43 | 44 | setting.value = value; 45 | await setting.save(); 46 | 47 | return setting.value; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/backend/src/app.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { Setting } from './entities/setting.entity'; 3 | 4 | const DEFAULT_PLAYERS = 4; 5 | const DEFAULT_ROUNDS = 10; 6 | const DEFAULT_ROUND_TIMER_IN_SECONDS = 15; 7 | const DEFAULT_WAITING_ROOM_TIMER_IN_SECONDS = 180000; 8 | @Injectable() 9 | export class AppService { 10 | async initializeSettings(): Promise { 11 | const playerSetting = await Setting.findOne('PLAYERS'); 12 | const roundSetting = await Setting.findOne('ROUND'); 13 | const roundTimerSetting = await Setting.findOne('ROUND_TIMER'); 14 | const waitingRoomTimerSetting = await Setting.findOne('WAITING_ROOM_TIMER'); 15 | if (!playerSetting) { 16 | await Setting.create({ 17 | settingName: 'PLAYERS', 18 | value: DEFAULT_PLAYERS, 19 | }).save(); 20 | } 21 | if (!roundSetting) { 22 | await Setting.create({ 23 | settingName: 'ROUND', 24 | value: DEFAULT_ROUNDS, 25 | }).save(); 26 | } 27 | if (!roundTimerSetting) { 28 | await Setting.create({ 29 | settingName: 'ROUND_TIMER', 30 | value: DEFAULT_ROUND_TIMER_IN_SECONDS, 31 | }).save(); 32 | } 33 | if (!waitingRoomTimerSetting) { 34 | await Setting.create({ 35 | settingName: 'WAITING_ROOM_TIMER', 36 | value: DEFAULT_WAITING_ROOM_TIMER_IN_SECONDS, 37 | }).save(); 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/backend/src/players/players.service.ts: -------------------------------------------------------------------------------- 1 | import { BadRequestException, Injectable } from '@nestjs/common'; 2 | import { InjectRepository } from '@nestjs/typeorm'; 3 | import { Player } from '../entities/player.entity'; 4 | import { Repository } from 'typeorm'; 5 | import { Game } from '../entities/game.entity'; 6 | 7 | @Injectable() 8 | export class PlayersService { 9 | constructor( 10 | @InjectRepository(Player) 11 | private playersRepository: Repository, 12 | @InjectRepository(Game) 13 | private readonly gamesRepository: Repository, 14 | ) {} 15 | 16 | async findAll(): Promise { 17 | return this.playersRepository.find(); 18 | } 19 | 20 | async findOne(id: number): Promise { 21 | const player = this.playersRepository.findOne(id); 22 | if (!player) { 23 | return; 24 | } 25 | return player; 26 | } 27 | 28 | // Changed the create to update since we already have @Post create() 29 | async update( 30 | playerId: number, 31 | gameId: number, 32 | color: string, 33 | ): Promise { 34 | let player = await this.findOne(playerId); 35 | player.color = color; 36 | 37 | const game = await this.gamesRepository.findOne(gameId); 38 | if (!game) { 39 | throw new BadRequestException('Game not found'); 40 | } 41 | player.game = game; 42 | 43 | await this.playersRepository.save(player); 44 | return player.id; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gratitudenu", 3 | "version": "1.0.0", 4 | "description": "Sandbox project for client studying impact of emotions on actions.", 5 | "main": "index.js", 6 | "repository": "https://github.com/sandboxnu/gratitudenu.git", 7 | "author": "Zefeng (Daniel) Wang ", 8 | "license": "MIT", 9 | "private": true, 10 | "scripts": { 11 | "dev": "nodemon --config nodemon.json src/index.ts", 12 | "dev:debug": "nodemon --config nodemon.json --inspect-brk src/index.ts", 13 | "lint": "eslint '*/**/*.{js,ts,tsx}' --quiet --fix", 14 | "start:dev:db": "./src/scripts/create-database.sh", 15 | "start:dev:backend": "cd src/backend && npm run start:dev", 16 | "start:dev:frontend": "cd src/frontend && npm run dev", 17 | "start:dev": "npm run start:dev:backend & npm run start:dev:frontend" 18 | }, 19 | "dependencies": { 20 | "@typescript-eslint/eslint-plugin": "^4.14.1", 21 | "@typescript-eslint/parser": "^4.14.1", 22 | "eslint": "^7.18.0", 23 | "eslint-config-prettier": "^7.2.0", 24 | "eslint-plugin-import": "^2.22.1", 25 | "husky": "^4.3.8", 26 | "prettier": "^2.2.1", 27 | "pretty-quick": "^3.1.0" 28 | }, 29 | "husky": { 30 | "hooks": { 31 | "pre-commit": "pretty-quick --staged" 32 | } 33 | }, 34 | "devDependencies": { 35 | "@types/node": "^14.14.22", 36 | "nodemon": "^2.0.7", 37 | "ts-node": "^9.1.1", 38 | "typescript": "^4.1.3" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/backend/src/sse/sse.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { Response } from 'express'; 3 | 4 | export interface Client { 5 | metadata: T; 6 | res: Response; 7 | } 8 | /** 9 | * T is metadata associated with each Client 10 | * 11 | * Low level abstraction for sending SSE to "rooms" of clients. 12 | * Probably don't use this directly, and wrap it in a service specific to that event source 13 | */ 14 | @Injectable() 15 | export class SSEService { 16 | private clients: Record[]> = {}; 17 | 18 | /** Add a client to a room */ 19 | subscribeClient(room: number, client: Client): void { 20 | // Keep track of responses so we can send sse through them 21 | if (!(room in this.clients)) { 22 | this.clients[room] = []; 23 | } 24 | const roomref = this.clients[room]; 25 | roomref.push(client); 26 | 27 | // Remove dead connections! 28 | client.res.socket.on('end', () => { 29 | roomref.splice(roomref.indexOf(client), 1); 30 | }); 31 | } 32 | 33 | unsubscribeRoom(room: number): void { 34 | const cli = this.clients[room]; 35 | delete this.clients[room]; 36 | cli.forEach((client) => client.res.end()); 37 | } 38 | 39 | /** Send some data to everyone in a room */ 40 | sendEvent(room: number, payload: D): void { 41 | if (room in this.clients) { 42 | for (const { res } of this.clients[room]) { 43 | const toSend = `data: ${JSON.stringify(payload)}\n\n`; 44 | res.write(toSend); 45 | } 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/backend/src/round/round.service.ts: -------------------------------------------------------------------------------- 1 | import { BadRequestException, Injectable } from '@nestjs/common'; 2 | import { InjectRepository } from '@nestjs/typeorm'; 3 | import { Round } from '../entities/round.entity'; 4 | import { Repository } from 'typeorm'; 5 | import { Game } from 'src/entities/game.entity'; 6 | 7 | @Injectable() 8 | export class RoundService { 9 | constructor( 10 | @InjectRepository(Round) 11 | private roundRepository: Repository, 12 | @InjectRepository(Game) 13 | private gameRepository: Repository, 14 | ) {} 15 | 16 | async findOne(id: number): Promise { 17 | const round = this.roundRepository.findOne(id); 18 | if (!round) { 19 | return; 20 | } 21 | return round; 22 | } 23 | 24 | // creates a new round 25 | async create( 26 | pointsRemaining: number, 27 | prevRoundNum: number, 28 | game: Game, 29 | ): Promise { 30 | const newRound = Round.create({ 31 | roundNumber: prevRoundNum + 1, 32 | pointsRemaining: pointsRemaining, 33 | game, 34 | }); 35 | await newRound.save(); 36 | return newRound; 37 | } 38 | 39 | async findByRoundNumber(roundNumber: number, gameId: number): Promise { 40 | const game = await this.gameRepository.findOne(gameId, { 41 | relations: ['rounds'], 42 | }); 43 | 44 | const roundId = game.rounds.find((r) => r.roundNumber === roundNumber).id; 45 | 46 | return this.roundRepository.findOne(roundId, { 47 | relations: ['playerMoves', 'game'], 48 | }); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/frontend/public/help-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/frontend/README.md: -------------------------------------------------------------------------------- 1 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). 2 | 3 | ## Getting Started 4 | 5 | First, run the development server: 6 | 7 | ```bash 8 | npm run dev 9 | # or 10 | yarn dev 11 | ``` 12 | 13 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 14 | 15 | You can start editing the page by modifying `pages/index.tsx`. The page auto-updates as you edit the file. 16 | 17 | [API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.js`. 18 | 19 | The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages. 20 | 21 | ## Learn More 22 | 23 | To learn more about Next.js, take a look at the following resources: 24 | 25 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 26 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 27 | 28 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! 29 | 30 | ## Deploy on Vercel 31 | 32 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 33 | 34 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. 35 | -------------------------------------------------------------------------------- /src/frontend/public/points.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /src/frontend/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import { useRouter } from 'next/router'; 2 | import React, { ReactElement, useState } from 'react'; 3 | import { API } from '../api-client'; 4 | import styles from '../styles/Home.module.scss'; 5 | import Head from 'next/head'; 6 | 7 | export default function Login(): ReactElement { 8 | const [userId, setUserId] = useState(''); 9 | const [emotionId, setEmotionId] = useState(''); 10 | const router = useRouter(); 11 | 12 | const onContinue = async () => { 13 | const uId = Number.parseInt(userId); 14 | const eId = Number.parseInt(emotionId); 15 | if (isNaN(uId) || isNaN(eId)) { 16 | //TODO: Add error messaging 17 | return; 18 | } 19 | const playerId = await API.player.create({ userId: uId, emotionId: eId }); 20 | await router.push(`/game-instructions?playerId=${playerId}`); 21 | }; 22 | 23 | const updateUserId = (event) => { 24 | setUserId(event.target.value); 25 | }; 26 | 27 | const updateEmotionId = (event) => { 28 | setEmotionId(event.target.value); 29 | }; 30 | return ( 31 |
32 | 33 | RDG NU | Sign-In Page 34 | 35 | 36 | 37 |
Decision Making Game
38 |
39 |
40 |
41 | USER ID 42 |
43 | 48 |
49 |
50 |
51 | ACCESS CODE 52 |
53 | 58 |
59 |
60 |
61 |
62 | 65 |
66 | ); 67 | } 68 | -------------------------------------------------------------------------------- /src/backend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "backend", 3 | "version": "0.0.1", 4 | "description": "", 5 | "author": "", 6 | "private": true, 7 | "license": "UNLICENSED", 8 | "scripts": { 9 | "prebuild": "rimraf dist", 10 | "build": "nest build", 11 | "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", 12 | "start": "nest start", 13 | "start:dev": "NODE_ENV=dev nest start --watch", 14 | "start:debug": "nest start --debug --watch", 15 | "start:prod": "node dist/main", 16 | "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", 17 | "test": "jest", 18 | "test:watch": "jest --watch", 19 | "test:cov": "jest --coverage", 20 | "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", 21 | "test:e2e": "jest --config ./test/jest-e2e.json" 22 | }, 23 | "dependencies": { 24 | "@nestjs/common": "^7.5.1", 25 | "@nestjs/config": "^0.6.3", 26 | "@nestjs/core": "^7.5.1", 27 | "@nestjs/platform-express": "^7.5.1", 28 | "@nestjs/typeorm": "^7.1.5", 29 | "papaparse": "^5.3.0", 30 | "pg": "^8.5.1", 31 | "reflect-metadata": "^0.1.13", 32 | "rimraf": "^3.0.2", 33 | "rxjs": "^6.6.3", 34 | "typeorm": "^0.2.30" 35 | }, 36 | "devDependencies": { 37 | "@nestjs/cli": "^7.5.1", 38 | "@nestjs/schematics": "^7.1.3", 39 | "@nestjs/testing": "^7.5.1", 40 | "@types/express": "^4.17.8", 41 | "@types/jest": "^26.0.15", 42 | "@types/node": "^14.14.6", 43 | "@types/supertest": "^2.0.10", 44 | "@typescript-eslint/eslint-plugin": "^4.6.1", 45 | "@typescript-eslint/parser": "^4.6.1", 46 | "eslint": "^7.12.1", 47 | "eslint-config-prettier": "7.2.0", 48 | "eslint-plugin-prettier": "^3.1.4", 49 | "jest": "^26.6.3", 50 | "prettier": "^2.1.2", 51 | "supertest": "^6.0.0", 52 | "ts-jest": "^26.4.3", 53 | "ts-loader": "^8.0.8", 54 | "ts-node": "^9.0.0", 55 | "tsconfig-paths": "^3.9.0", 56 | "typescript": "^4.0.5" 57 | }, 58 | "jest": { 59 | "moduleFileExtensions": [ 60 | "js", 61 | "json", 62 | "ts" 63 | ], 64 | "rootDir": "src", 65 | "testRegex": ".*\\.spec\\.ts$", 66 | "transform": { 67 | "^.+\\.(t|j)s$": "ts-jest" 68 | }, 69 | "collectCoverageFrom": [ 70 | "**/*.(t|j)s" 71 | ], 72 | "coverageDirectory": "../coverage", 73 | "testEnvironment": "node" 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/backend/src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { AppService } from './app.service'; 3 | import { TypeOrmModule } from '@nestjs/typeorm'; 4 | import { Connection } from 'typeorm'; 5 | import { Grab } from './entities/grab.entity'; 6 | import { Player } from './entities/player.entity'; 7 | import { Round } from './entities/round.entity'; 8 | import { Game } from './entities/game.entity'; 9 | import { SSEService } from './sse/sse.service'; 10 | import { WaitingRoomSSEService } from './waiting-room/waiting-room.sse.service'; 11 | import { PlayersModule } from './players/players.module'; 12 | import { PlayersService } from './players/players.service'; 13 | import { AppController } from './app.controller'; 14 | import { WaitingRoomController } from './waiting-room/waiting-room.controller'; 15 | import { PlayersController } from './players/players.controller'; 16 | import { GameController } from './game/game.controller'; 17 | import { GameService } from './game/game.service'; 18 | import { RoundService } from './round/round.service'; 19 | import { GameModule } from './game/game.module'; 20 | import { ConfigModule } from '@nestjs/config'; 21 | import { GameSseService } from './game/game.sse.service'; 22 | import { ExportController } from './export/export.controller'; 23 | import { Setting } from './entities/setting.entity'; 24 | import { AdminController } from './admin/admin.controller'; 25 | import { AdminService } from './admin/admin.service'; 26 | 27 | @Module({ 28 | imports: [ 29 | ConfigModule.forRoot({ 30 | envFilePath: [ 31 | process.env.NODE_ENV !== 'production' ? '.env.dev' : '.env', 32 | ], 33 | isGlobal: true, 34 | }), 35 | TypeOrmModule.forRoot({ 36 | type: 'postgres', 37 | url: process.env.DB_URL, 38 | entities: [Grab, Player, Round, Game, Setting], 39 | synchronize: true, // TODO: synchronize true should not be used in a production environment 40 | }), 41 | PlayersModule, 42 | GameModule, 43 | ], 44 | 45 | providers: [ 46 | AppService, 47 | PlayersService, 48 | WaitingRoomSSEService, 49 | SSEService, 50 | GameService, 51 | GameSseService, 52 | RoundService, 53 | AdminService, 54 | ], 55 | controllers: [ 56 | AppController, 57 | WaitingRoomController, 58 | PlayersController, 59 | GameController, 60 | ExportController, 61 | AdminController, 62 | ], 63 | }) 64 | export class AppModule { 65 | constructor(private connection: Connection, private appService: AppService) { 66 | this.appService.initializeSettings(); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/backend/src/export/export.controller.ts: -------------------------------------------------------------------------------- 1 | import { Body, Controller, Post } from '@nestjs/common'; 2 | import { Game } from 'src/entities/game.entity'; 3 | import * as Papa from 'papaparse'; 4 | import { AdminService } from 'src/admin/admin.service'; 5 | 6 | const BASE_COLUMNS = [ 7 | 'game', 8 | 'emotion', 9 | 'userId', 10 | 'gameId', 11 | 'emotionId', 12 | 'totalRounds', 13 | 'totalPointsTaken', 14 | ]; 15 | @Controller('export') 16 | export class ExportController { 17 | constructor(private adminService: AdminService) {} 18 | 19 | @Post() 20 | async export(@Body('password') password: string): Promise { 21 | let maxRounds = 0; 22 | this.adminService.verifyPassword(password); 23 | const data = []; 24 | // find games 25 | const allFinishedGames = await Game.find({ 26 | ongoing: false, 27 | }); 28 | 29 | // fill out each game with relations 30 | const gamesWithRelations = await Promise.all( 31 | allFinishedGames.map( 32 | async (game) => 33 | await Game.findOne(game.id, { 34 | relations: [ 35 | 'players', 36 | 'rounds', 37 | 'players.grabs', 38 | 'players.grabs.round', 39 | ], 40 | }), 41 | ), 42 | ); 43 | 44 | gamesWithRelations.forEach((game) => { 45 | if (game.rounds.length > maxRounds) { 46 | maxRounds = game.rounds.length; 47 | } 48 | this.formatGameToCsv(game, data); 49 | }); 50 | 51 | const csv = Papa.unparse(data, { 52 | columns: this.generateColumns(maxRounds), 53 | }); 54 | 55 | return csv; 56 | } 57 | 58 | private formatGameToCsv(game: Game, data: Record[]) { 59 | data.push({ 60 | game: `Game: ${game.id}`, 61 | emotion: `Emotion: ${game.players[0].emotionId}`, 62 | }); 63 | game.players.forEach((player) => { 64 | const playerData = { 65 | userId: player.userId, 66 | gameId: game.id, 67 | emotionId: player.emotionId, 68 | totalRounds: player.grabs.length, 69 | }; 70 | let totalPoints = 0; 71 | 72 | player.grabs.forEach((grab) => { 73 | playerData[`round${grab.round.roundNumber}Take`] = grab.howMany; 74 | playerData[`round${grab.round.roundNumber}Time`] = grab.timeTaken; 75 | totalPoints += grab.howMany; 76 | }); 77 | playerData['totalPointsTaken'] = totalPoints; 78 | 79 | data.push(playerData); 80 | }); 81 | } 82 | 83 | private generateColumns(maxRounds: number): string[] { 84 | const columns = BASE_COLUMNS.slice(); 85 | 86 | for (let i = 1; i <= maxRounds; i++) { 87 | columns.push(`round${i}Take`, `round${i}Time`); 88 | } 89 | return columns; 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/backend/src/game/game.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { InjectRepository } from '@nestjs/typeorm'; 3 | import { Player } from '../entities/player.entity'; 4 | import { Repository } from 'typeorm'; 5 | import { Game } from '../entities/game.entity'; 6 | import { Round } from '../entities/round.entity'; 7 | import { Grab } from 'src/entities/grab.entity'; 8 | import { Setting } from 'src/entities/setting.entity'; 9 | 10 | const MAX_POINTS = 200; 11 | 12 | type GameRoundID = { 13 | gameId: number; 14 | }; 15 | 16 | const ROUND_SETTING_NAME = 'ROUND'; 17 | 18 | @Injectable() 19 | export class GameService { 20 | constructor( 21 | @InjectRepository(Player) 22 | private playersRepository: Repository, 23 | @InjectRepository(Game) 24 | private gamesRepository: Repository, 25 | @InjectRepository(Round) 26 | private roundRepository: Repository, 27 | ) {} 28 | 29 | // create the initial round 30 | async create(playerIds: number[]): Promise { 31 | const players = await Promise.all( 32 | playerIds.map(async (id) => { 33 | return await this.playersRepository.findOne(id); 34 | }), 35 | ); 36 | 37 | const roundSetting = await Setting.findOne(ROUND_SETTING_NAME); 38 | const maxRounds = roundSetting.value; 39 | 40 | const game = await Game.create({ 41 | ongoing: true, 42 | players, 43 | maxRounds, 44 | }).save(); 45 | 46 | await Round.create({ 47 | roundNumber: 1, 48 | pointsRemaining: MAX_POINTS, 49 | game, 50 | }).save(); 51 | 52 | return { gameId: game.id }; 53 | } 54 | 55 | // get points remaining 56 | async getSumPoints(roundId: number): Promise { 57 | const round = await this.roundRepository.findOne(roundId, { 58 | relations: ['playerMoves'], 59 | }); 60 | const prevSumPoints = round.pointsRemaining; 61 | const sumPoints = (acc, cur: Grab) => acc + cur.howMany; 62 | const totalGrabs = round.playerMoves.reduce(sumPoints, 0); 63 | 64 | const currSumPoints = Math.round(prevSumPoints - totalGrabs * 0.9); // give back 10% 65 | return currSumPoints; 66 | } 67 | 68 | async findOne(id: number): Promise { 69 | const game = this.gamesRepository.findOne(id, { 70 | relations: ['rounds'], 71 | }); 72 | if (!game) { 73 | return; 74 | } 75 | return game; 76 | } 77 | 78 | async updateOngoing(gameId: number, roundId: number): Promise { 79 | const game: Game = await this.findOne(gameId); 80 | // no points remaining, or max round 81 | const pointsRemaining = await this.getSumPoints(roundId); 82 | const roundCount = game.rounds.length; 83 | if (pointsRemaining <= 0 || roundCount >= game.maxRounds) { 84 | game.ongoing = false; 85 | await game.save(); 86 | } 87 | return game.ongoing; 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/frontend/public/slider.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /src/frontend/api-client/index.ts: -------------------------------------------------------------------------------- 1 | import Axios, { AxiosInstance, Method } from 'axios'; 2 | import { plainToClass } from 'class-transformer'; 3 | import { ClassType } from 'class-transformer/ClassTransformer'; 4 | 5 | // Return type of array item, if T is an array 6 | type ItemIfArray = T extends (infer I)[] ? I : T; 7 | export const API_URL = process.env.NEXT_PUBLIC_GRATITUDE_API_URL 8 | ? process.env.NEXT_PUBLIC_GRATITUDE_API_URL 9 | : 'http://localhost:3001'; 10 | 11 | class APIClient { 12 | private axios: AxiosInstance; 13 | 14 | /** 15 | * Send HTTP and return data, optionally serialized with class-transformer (helpful for Date serialization) 16 | * @param method HTTP method 17 | * @param url URL to send req to 18 | * @param responseClass Class with class-transformer decorators to serialize response to 19 | * @param body body to send with req 20 | */ 21 | private async req( 22 | method: Method, 23 | url: string, 24 | responseClass?: ClassType>, 25 | body?: any, 26 | ): Promise; 27 | private async req( 28 | method: Method, 29 | url: string, 30 | responseClass?: ClassType, 31 | body?: any, 32 | ): Promise { 33 | const res = ( 34 | await this.axios.request({ 35 | method, 36 | url, 37 | data: body, 38 | headers: { 'Access-Control-Allow-Origin': '*' }, 39 | }) 40 | ).data; 41 | return responseClass ? plainToClass(responseClass, res) : res; 42 | } 43 | 44 | player = { 45 | create: async (body: { 46 | userId: number; 47 | emotionId: number; 48 | }): // eslint-disable-next-line @typescript-eslint/ban-types 49 | Promise => { 50 | return this.req('POST', '/players', Number, body); 51 | }, 52 | }; 53 | 54 | game = { 55 | take: async (body: { 56 | playerId: number; 57 | howMany: number; 58 | timeTaken: number; 59 | roundNumber: number; 60 | }): // eslint-disable-next-line @typescript-eslint/ban-types 61 | Promise => { 62 | return this.req('POST', '/game/take', Number, body); 63 | }, 64 | }; 65 | 66 | export = { 67 | // eslint-disable-next-line @typescript-eslint/ban-types 68 | export: async (body: { password: string }): Promise => { 69 | return this.req('POST', '/export', String, body); 70 | }, 71 | }; 72 | 73 | settings = { 74 | // eslint-disable-next-line @typescript-eslint/ban-types 75 | get: async (settingName: string): Promise => { 76 | return this.req('GET', `/admin?settingName=${settingName}`, Number); 77 | }, 78 | 79 | update: async (body: { 80 | settingName: string; 81 | value: number; 82 | password: string; 83 | }): // eslint-disable-next-line @typescript-eslint/ban-types 84 | Promise => { 85 | return this.req('PATCH', `/admin/setting`, Number, body); 86 | }, 87 | }; 88 | 89 | constructor(baseURL = '') { 90 | this.axios = Axios.create({ baseURL: baseURL }); 91 | } 92 | } 93 | 94 | export const API = new APIClient(API_URL); 95 | -------------------------------------------------------------------------------- /src/frontend/pages/waiting-room.tsx: -------------------------------------------------------------------------------- 1 | import { useRouter } from 'next/dist/client/router'; 2 | import { ReactElement, useState } from 'react'; 3 | import { API_URL } from '../api-client'; 4 | import Timer from '../components/timer'; 5 | import { useEventSource } from '../hooks/useEventSource'; 6 | import { useSetting } from '../hooks/useSetting'; 7 | import styles from '../styles/WaitingRoom.module.scss'; 8 | import { PLAYERS } from './admin'; 9 | 10 | export default function WaitingRoom(): ReactElement { 11 | const router = useRouter(); 12 | const playersPerGame = useSetting(PLAYERS); 13 | const waitingRoomTimer = useSetting('WAITING_ROOM_TIMER'); 14 | 15 | const { playerId } = router.query; 16 | const [players, setPlayers] = useState(1); // assume it is just us to begin with 17 | const waitingRoomUrl = `${API_URL}/waiting-room?playerId=${playerId}`; 18 | 19 | // subscribe to waiting room on load 20 | useEventSource(waitingRoomUrl, (message) => { 21 | if (message.players) { 22 | setPlayers(message.players); 23 | } else if (message.timeout) { 24 | router.push(`/thank-you`); 25 | } else if (message.gameId) { 26 | router.push(`/game?gameId=${message.gameId.gameId}&playerId=${playerId}`); 27 | } 28 | }); 29 | 30 | const formatTimeIntoMinutes = (timer: number) => { 31 | const minutes = Math.floor(timer / 60); 32 | 33 | const seconds = timer % 60; 34 | return `${minutes}:${seconds < 10 ? `0${seconds}` : seconds}`; 35 | }; 36 | if (!waitingRoomTimer) { 37 | return
; // this doesn't actually render, but meh 38 | } 39 | 40 | return ( 41 |
42 |
43 |
You are in the waiting room
44 | 49 |
50 |
51 |
52 |
Game Instructions
53 |
    54 |
  1. 55 | Select the amount of points you will take for each round (1-10) 56 | using the slider or the input box. 57 |
  2. 58 |
  3. Click the "Take" button to receive your points.
  4. 59 |
  5. 60 | Points will be replenished by 10% at the end of every round. 61 | Continue taking points until the game is over. 62 |
  6. 63 |
64 |
65 |
66 |
67 | The game will begin when the room is filled: 68 |
69 |
70 | {players}/{playersPerGame} 71 |
72 |
73 |
74 |
75 | ); 76 | } 77 | -------------------------------------------------------------------------------- /src/backend/README.md: -------------------------------------------------------------------------------- 1 |

2 | Nest Logo 3 |

4 | 5 | [circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456 6 | [circleci-url]: https://circleci.com/gh/nestjs/nest 7 | 8 |

A progressive Node.js framework for building efficient and scalable server-side applications.

9 |

10 | NPM Version 11 | Package License 12 | NPM Downloads 13 | CircleCI 14 | Coverage 15 | Discord 16 | Backers on Open Collective 17 | Sponsors on Open Collective 18 | 19 | Support us 20 | 21 |

22 | 24 | 25 | ## Description 26 | 27 | [Nest](https://github.com/nestjs/nest) framework TypeScript starter repository. 28 | 29 | ## Installation 30 | 31 | ```bash 32 | $ npm install 33 | ``` 34 | 35 | ## Running the app 36 | 37 | ```bash 38 | # development 39 | $ npm run start 40 | 41 | # watch mode 42 | $ npm run start:dev 43 | 44 | # production mode 45 | $ npm run start:prod 46 | ``` 47 | 48 | ## Test 49 | 50 | ```bash 51 | # unit tests 52 | $ npm run test 53 | 54 | # e2e tests 55 | $ npm run test:e2e 56 | 57 | # test coverage 58 | $ npm run test:cov 59 | ``` 60 | 61 | ## Support 62 | 63 | Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support). 64 | 65 | ## Stay in touch 66 | 67 | - Author - [Kamil Myśliwiec](https://kamilmysliwiec.com) 68 | - Website - [https://nestjs.com](https://nestjs.com/) 69 | - Twitter - [@nestframework](https://twitter.com/nestframework) 70 | 71 | ## License 72 | 73 | Nest is [MIT licensed](LICENSE). 74 | -------------------------------------------------------------------------------- /src/backend/src/waiting-room/waiting-room.sse.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { Response } from 'express'; 3 | import { Setting } from 'src/entities/setting.entity'; 4 | import { GameService } from 'src/game/game.service'; 5 | import { Client } from 'src/sse/sse.service'; 6 | 7 | type WaitingRoomClientMetadata = { playerId: number; emotionId: number }; 8 | const WAITING_ROOM_TIME = 180000; 9 | const TIMEOUT_EVENT = { timeout: true }; 10 | /** 11 | * Handle sending Waiting Room sse events 12 | */ 13 | @Injectable() 14 | export class WaitingRoomSSEService { 15 | private clients: Record[]> = {}; 16 | constructor(private gameService: GameService) {} 17 | /** 18 | * Subscribe a Client to the Waiting Room 19 | * @param res 20 | * @param metadata 21 | */ 22 | async subscribeClient( 23 | res: Response, 24 | metadata: WaitingRoomClientMetadata, 25 | ): Promise { 26 | const waitingRoomTimer = 27 | (await Setting.findOne('WAITING_ROOM_TIMER')).value || WAITING_ROOM_TIME; 28 | if (!(metadata.emotionId in this.clients)) { 29 | this.clients[metadata.emotionId] = []; 30 | } 31 | const room = this.clients[metadata.emotionId]; 32 | 33 | if (room.find((client) => client.metadata.playerId === metadata.playerId)) { 34 | return; 35 | } 36 | // Start Timer to remove player 37 | setTimeout(() => this.clientTimerFunction(metadata), waitingRoomTimer); 38 | // Add Client to emotion room 39 | this.clients[metadata.emotionId].push({ res, metadata }); 40 | await this.updateEmotionRoomWithNumberOfPlayers( 41 | this.clients[metadata.emotionId], 42 | ); 43 | 44 | const playerSetting = await Setting.findOne('PLAYERS'); 45 | const maxPlayers = playerSetting.value; 46 | 47 | if (this.clients[metadata.emotionId].length === maxPlayers) { 48 | await this.sendClientsToGame(this.clients[metadata.emotionId]); 49 | delete this.clients[metadata.emotionId]; 50 | } 51 | } 52 | 53 | clientTimerFunction(client: WaitingRoomClientMetadata): void { 54 | const clients = this.clients[client.emotionId]; 55 | const index = this.clients[client.emotionId]?.findIndex( 56 | (cli) => cli.metadata.playerId === client.playerId, 57 | ); 58 | if (clients && index !== undefined) { 59 | const cli = this.clients[client.emotionId][index]; 60 | this.sendMessage(TIMEOUT_EVENT, [cli]); 61 | clients.splice(index, 1); 62 | this.updateEmotionRoomWithNumberOfPlayers(clients); 63 | cli.res.end(); 64 | } 65 | } 66 | 67 | /** 68 | * Update Room with number of players waiting for games 69 | * @param clients 70 | */ 71 | updateEmotionRoomWithNumberOfPlayers( 72 | clients: Client[], 73 | ): void { 74 | this.sendMessage({ players: clients.length }, clients); 75 | } 76 | 77 | /** 78 | * Send clients to game room 79 | * @param clients 80 | */ 81 | async sendClientsToGame( 82 | clients: Client[], 83 | ): Promise { 84 | // create game with the given clients 85 | const playerIds = clients.map((client) => client.metadata.playerId); 86 | const gameId = await this.gameService.create(playerIds); 87 | // send each client the game id 88 | this.sendMessage({ gameId }, clients); 89 | 90 | // close each client connection 91 | for (const { res } of clients) { 92 | res.end(); 93 | } 94 | } 95 | 96 | /** 97 | * Send message to clients 98 | * @param message 99 | * @param clients 100 | */ 101 | private sendMessage( 102 | message: any, 103 | clients: Client[], 104 | ) { 105 | for (const { res } of clients) { 106 | const toSend = `data: ${JSON.stringify(message)}\n\n`; 107 | res.write(toSend); 108 | } 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/backend/src/game/game.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BadRequestException, 3 | Body, 4 | Controller, 5 | Get, 6 | Post, 7 | Query, 8 | Res, 9 | } from '@nestjs/common'; 10 | import { InjectRepository } from '@nestjs/typeorm'; 11 | import { Player } from '../entities/player.entity'; 12 | import { Repository } from 'typeorm'; 13 | import { Grab } from '../entities/grab.entity'; 14 | import { Round } from '../entities/round.entity'; 15 | import { GameSseService } from './game.sse.service'; 16 | import { Response } from 'express'; 17 | import { GameService } from './game.service'; 18 | import { RoundService } from '../round/round.service'; 19 | 20 | @Controller('game') 21 | export class GameController { 22 | constructor( 23 | @InjectRepository(Player) 24 | private playersRepository: Repository, 25 | private roundService: RoundService, 26 | private gameSseService: GameSseService, 27 | private gameService: GameService, 28 | ) {} 29 | 30 | @Get('sse') 31 | async subscribePlayer( 32 | @Query('playerId') playerId: number, 33 | @Query('gameId') gameId: number, 34 | @Res() res: Response, 35 | ): Promise { 36 | const player = await this.playersRepository.findOne(playerId, { 37 | relations: ['game'], 38 | }); 39 | if (player.game.id != gameId) { 40 | throw new BadRequestException( 41 | `Player ${playerId} does not belong to game ${gameId}`, 42 | ); 43 | } 44 | 45 | res.set({ 46 | 'Content-Type': 'text/event-stream', 47 | 'Cache-Control': 'no-cache', 48 | 'X-Accel-Buffering': 'no', 49 | Connection: 'keep-alive', 50 | }); 51 | 52 | await this.gameSseService.subscribeClient(res, { 53 | playerId, 54 | gameId, 55 | }); 56 | } 57 | 58 | @Post('take') 59 | async take( 60 | @Body('playerId') playerId: number, 61 | @Body('howMany') howMany: number, 62 | @Body('timeTaken') timeTaken: number, 63 | @Body('roundNumber') roundNumber: number, 64 | ): Promise { 65 | const player = await this.playersRepository.findOne(playerId, { 66 | relations: ['grabs', 'game'], 67 | }); 68 | 69 | let round = await this.roundService.findByRoundNumber( 70 | roundNumber, 71 | player.game.id, 72 | ); // Check for updates 73 | this.validatePlayerAndRound(player, round); 74 | 75 | const grab = await Grab.create({ 76 | round, 77 | player, 78 | howMany, 79 | timeTaken, 80 | }).save(); 81 | 82 | // await round.reload(); 83 | round = await Round.findOne(round.id, { 84 | relations: ['playerMoves', 'game', 'game.players'], 85 | }); 86 | 87 | if (round.playerMoves.length === round.game.players.length) { 88 | // check if game is over 89 | const isOngoing = await this.gameService.updateOngoing( 90 | round.game.id, 91 | round.id, 92 | ); 93 | if (isOngoing) { 94 | // Create new Round after calculating remaining points 95 | const game = await this.gameService.findOne(round.game.id); 96 | const adjustedTotal: number = await this.gameService.getSumPoints( 97 | round.id, 98 | ); 99 | const newRound = await this.roundService.create( 100 | adjustedTotal, 101 | round.roundNumber, 102 | game, 103 | ); 104 | 105 | // Send round results to all players 106 | this.gameSseService.updateGameWithRoundResults(game.id, newRound); 107 | } else { 108 | // stop game 109 | this.gameSseService.endGame(round.game.id); 110 | } 111 | } 112 | 113 | return grab.id; 114 | } 115 | 116 | private validatePlayerAndRound(player: Player, round: Round) { 117 | if (!round) { 118 | throw new BadRequestException('Round does not exist'); 119 | } 120 | 121 | if (!player) { 122 | throw new BadRequestException('Player does not exist'); 123 | } 124 | 125 | if ( 126 | round.playerMoves.find((grab) => 127 | player.grabs.find((playerGrab) => playerGrab.id === grab.id), 128 | ) 129 | ) { 130 | throw new BadRequestException( 131 | 'This Player has already taken a turn this round', 132 | ); 133 | } 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /src/frontend/pages/game-instructions.tsx: -------------------------------------------------------------------------------- 1 | import { ReactElement } from 'react'; 2 | import styles from '../styles/GameInstructions.module.scss'; 3 | import Image from 'next/image'; 4 | import { useRouter } from 'next/router'; 5 | import { useSetting } from '../hooks/useSetting'; 6 | 7 | export default function GameInstructions(): ReactElement { 8 | const router = useRouter(); 9 | 10 | const roundLengthTimer = useSetting('ROUND_TIMER'); 11 | 12 | const { playerId } = router.query; 13 | const onContinue = async () => { 14 | await router.push(`/practice-game?playerId=${playerId}`); 15 | }; 16 | 17 | return ( 18 |
19 |
20 | Decision Making Game Instructions 21 |
22 |
23 | You will each be assigned a color identifier. 24 |
25 |
26 | players 27 |
28 |
29 | You all have access to a common pool of points. The common pool starts 30 | with 200 points. 31 |
32 |
33 | In each round, you will decide how many points (between 0-10) to take 34 | for yourself from the common pool. These points will not go to the other 35 | players. Whatever (if any) points you do not take will be left in the 36 | common pool. To make sure everyone is playing at a similar pace, you 37 | will have up to {roundLengthTimer} seconds to make your decision. 38 |
39 |
40 | Example: Player green makes decision. 41 |
42 |
43 | slider 44 |
45 |
46 | After each round, you will see how many points were taken by the other 47 | team members (and vice versa). 48 |
49 |
50 | Example: Player blue and amount of points taken. 51 |
52 |
53 | points 54 |
55 |
56 | The total number of points taken in the round will be subtracted from 57 | the common pool. 58 |
59 |
60 | Example: If the total number of points taken in Round 1 is 40 points. 61 |
62 |
63 | points taken 69 |
70 |
71 | At the end of the round, the common points pool will be replenished by 72 | 10% of the amount of points taken collectively that round. This new 73 | total will be the common pool for the next round, and each player will 74 | again decide how many points to take. 75 |
76 |
77 | Example: New common pool for Round 2. 78 |
79 |
80 | points replenished 86 |
87 |
88 | You can continue to take points as long as there are still points left 89 | in the common pool. If the pool drops to zero points, the game will end. 90 | This is a game of strategy where you will need to consider the amount 91 | you take out per round and how many rounds of the game will be played in 92 | total. 93 |
94 |
95 | When you feel that you have understood the instructions, please continue 96 | for a practice round. 97 |
98 |
99 | 100 | PLEASE NOTE: There is no timer during the practice round, you will 101 | have to press the "Take" button to move to the next round. During the 102 | actual game, there will be a {roundLengthTimer} second timer for each 103 | round. 104 | 105 |
106 |
107 | 110 |
111 |
112 | ); 113 | } 114 | -------------------------------------------------------------------------------- /src/frontend/styles/Game.module.scss: -------------------------------------------------------------------------------- 1 | @import 'constants'; 2 | 3 | .container { 4 | min-height: 100vh; 5 | padding: 0 0.5rem; 6 | display: flex; 7 | flex-direction: column; 8 | justify-content: center; 9 | align-items: center; 10 | } 11 | 12 | .main { 13 | padding: 10px 0; 14 | flex: 1; 15 | display: flex; 16 | flex-direction: column; 17 | justify-content: center; 18 | align-items: center; 19 | } 20 | 21 | .footer { 22 | margin-top: 35px; 23 | width: 100%; 24 | height: 100px; 25 | border-top: 1px solid #eaeaea; 26 | display: flex; 27 | justify-content: center; 28 | align-items: center; 29 | } 30 | 31 | .footer img { 32 | margin-left: 0.5rem; 33 | } 34 | 35 | .footer a { 36 | display: flex; 37 | justify-content: center; 38 | align-items: center; 39 | } 40 | 41 | .actionBar { 42 | min-height: 20vh; 43 | min-width: 70vw; 44 | display: flex; 45 | justify-content: space-around; 46 | align-items: center; 47 | background: #f7f7f7; 48 | box-shadow: 0px 4px 4px rgba(0, 0, 0, 0.25); 49 | border-radius: 10px; 50 | } 51 | 52 | .actionBarLeft { 53 | justify-content: left; 54 | } 55 | 56 | .actionBarText { 57 | font-size: 22px; 58 | color: $dark-blue; 59 | } 60 | 61 | .actionBarMiddle { 62 | justify-content: center; 63 | } 64 | .actionBarInput { 65 | border: 1px solid $dark-blue; 66 | box-sizing: border-box; 67 | border-radius: 4px; 68 | width: 70px; 69 | height: 44px; 70 | color: $dark-blue; 71 | font-size: 26px; 72 | text-align: center; 73 | } 74 | 75 | .actionBarRight { 76 | justify-content: right; 77 | } 78 | 79 | .inputBox { 80 | margin-right: 15px; 81 | } 82 | 83 | .gameDisplay { 84 | margin: 5% 0; 85 | position: relative; 86 | } 87 | 88 | .actionBarTake { 89 | background: $dark-purple; 90 | box-shadow: 0 4px 4px rgba(0, 0, 0, 0.25); 91 | border-radius: 10px; 92 | color: white; 93 | width: 140px; 94 | height: 60px; 95 | font-size: 27px; 96 | transition: 0.3s; 97 | border: none; 98 | text-transform: uppercase; 99 | font-weight: bold; 100 | 101 | &:hover { 102 | opacity: 0.8; 103 | cursor: pointer; 104 | } 105 | } 106 | 107 | .slider { 108 | margin-left: 20px; 109 | } 110 | 111 | .gameTable { 112 | min-height: 20vh; 113 | min-width: 70vw; 114 | display: flex; 115 | justify-content: space-around; 116 | align-items: center; 117 | } 118 | 119 | .gameTableColumn { 120 | display: flex; 121 | flex-direction: column; 122 | align-items: center; 123 | } 124 | 125 | .topPlayer { 126 | margin-bottom: 40px; 127 | } 128 | 129 | .gameTimer { 130 | position: absolute; 131 | bottom: -45px; 132 | right: -30px; 133 | height: 50px; 134 | width: 105px; 135 | background: #f6f6f6; 136 | box-shadow: 0 4px 4px rgba(0, 0, 0, 0.25); 137 | border-radius: 6px; 138 | text-align: center; 139 | line-height: 50px; 140 | font-size: 35px; 141 | font-family: Roboto, sans-serif; 142 | font-weight: 500; 143 | color: $dark-blue; 144 | } 145 | 146 | .progressBarTextTop { 147 | font-size: 50px; 148 | color: $dark-blue; 149 | font-weight: 700; 150 | } 151 | 152 | .progressBarTextBottom { 153 | font-size: 35px; 154 | color: $dark-blue; 155 | font-weight: 500; 156 | } 157 | 158 | .infoSection { 159 | display: flex; 160 | justify-content: space-around; 161 | align-items: center; 162 | } 163 | 164 | .infoSectionTitle { 165 | font-size: 48px; 166 | font-weight: 700; 167 | color: $dark-blue; 168 | padding-right: 20px; 169 | } 170 | 171 | .timer { 172 | background: #f6f6f6; 173 | box-shadow: 0 4px 4px rgba(0, 0, 0, 0.25); 174 | border-radius: 6px; 175 | text-align: center; 176 | font-size: 35px; 177 | font-family: Roboto, sans-serif; 178 | font-weight: 500; 179 | color: $dark-blue; 180 | width: 105px; 181 | padding: 15px; 182 | margin: 5% 5% -3% 81.5%; 183 | } 184 | 185 | .turnRed { 186 | background: #f6f6f6; 187 | box-shadow: 0 4px 4px rgba(0, 0, 0, 0.25); 188 | border-radius: 6px; 189 | text-align: center; 190 | font-size: 35px; 191 | font-family: Roboto, sans-serif; 192 | font-weight: 500; 193 | color: red; 194 | width: 105px; 195 | padding: 15px; 196 | margin: 5% 5% -3% 81.5%; 197 | } 198 | 199 | .fakeTimer { 200 | background: #f6f6f6; 201 | box-shadow: 0 4px 4px rgba(0, 0, 0, 0.25); 202 | border-radius: 6px; 203 | text-align: center; 204 | font-size: 24px; 205 | font-family: Roboto, sans-serif; 206 | font-weight: 500; 207 | width: 150px; 208 | padding: 15px; 209 | margin: 5% 5% -3% 81.5%; 210 | } 211 | 212 | .instructionsText { 213 | color: $dark-blue; 214 | padding: 0 20px; 215 | } 216 | 217 | .instructionsText ol { 218 | padding-left: 20px; 219 | } 220 | 221 | .instructionsText li { 222 | padding-bottom: 8px; 223 | } 224 | 225 | .gameOverModal { 226 | position: relative; 227 | width: 350px; 228 | height: 230px; 229 | font-size: 40px; 230 | color: $dark-blue; 231 | text-align: center; 232 | top: 50%; 233 | left: 50%; 234 | right: auto; 235 | bottom: auto; 236 | margin-right: -50%; 237 | transform: translate(-50%, -50%); 238 | background: #f6f6f6; 239 | box-shadow: 0 8px 8px rgba(0, 0, 0, 0.35); 240 | overflow: auto; 241 | border-radius: 8px; 242 | padding: 70px 10px 0 10px; 243 | font-weight: 600; 244 | display: flex; 245 | flex-direction: column; 246 | } 247 | 248 | .gameOverButton { 249 | font-size: 24px; 250 | color: $dark-blue; 251 | text-align: center; 252 | font-weight: 600; 253 | max-width: 75%; 254 | margin: auto; 255 | border-radius: 5px; 256 | } 257 | -------------------------------------------------------------------------------- /src/frontend/pages/practice-game.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement, useState } from 'react'; 2 | import gameConstants from '../constants/gameConstants'; 3 | import { useRouter } from 'next/dist/client/router'; 4 | import styles from '../styles/Game.module.scss'; 5 | import Head from 'next/head'; 6 | import Image from 'next/image'; 7 | import Modal from 'react-modal'; 8 | import Colors from '../constants/colorConstants'; 9 | import Slider from 'react-input-slider'; 10 | import { GameInstructionsModal, GameTable } from './game'; 11 | 12 | export default function PracticeGame(): ReactElement { 13 | const [pointsRemaining, setPointsRemaining] = useState( 14 | gameConstants.INIT_TOTAL_COINS, 15 | ); 16 | const [takeVal, setTakeVal] = useState(gameConstants.MIN_TAKE_VAL); 17 | const [modalIsOpen, setModalIsOpen] = useState(false); 18 | const [playerPoints, setPlayerPoints] = useState( 19 | gameConstants.INIT_PLAYER_COINS, 20 | ); 21 | const [roundNumber, setRoundNumber] = useState(1); 22 | const router = useRouter(); 23 | const { playerId } = router.query; 24 | const [gameOverModalIsOpen, setGameOverModalIsOpen] = useState( 25 | false, 26 | ); 27 | 28 | const handleTake = () => { 29 | setPlayerPoints(playerPoints + takeVal); 30 | setRoundNumber(roundNumber + 1); 31 | setGameOverModalIsOpen(roundNumber >= 2); 32 | 33 | // Take random number of points from pot 34 | if (!gameOverModalIsOpen) { 35 | setPointsRemaining( 36 | pointsRemaining - (takeVal + 3 * Math.floor(Math.random() * 9)), 37 | ); 38 | } 39 | }; 40 | 41 | const handleContinueClick = () => { 42 | router.push(`/waiting-room?playerId=${playerId}`); 43 | }; 44 | 45 | return ( 46 |
47 | 48 | RDG NU | Practice Game 49 | 50 | 51 | 52 |
53 | 58 | Practice Round Over 59 | 65 | 66 |
67 |

Practice Rounds

68 | {'Help setModalIsOpen(true)} 74 | /> 75 | setModalIsOpen(false)} 78 | style={{ 79 | content: { 80 | top: '20%', 81 | left: '60%', 82 | right: 'auto', 83 | bottom: 'auto', 84 | marginRight: '-50%', 85 | transform: 'translate(-50%, -50%)', 86 | }, 87 | }} 88 | contentLabel="Instructions Modal" 89 | > 90 | 91 | 92 |
93 | 94 |
95 | 96 |
Timer Will Be Here
97 |
98 | 99 |
100 |
101 |

102 | You Are:{' '} 103 | 106 | {gameConstants.DEFAULT_COLOR}{' '} 107 | 108 |

109 |

110 | Your Total Points:{' '} 111 | {playerPoints} 112 |

113 |
114 |
115 | 123 | 124 | 125 | setTakeVal(x)} 129 | styles={{ 130 | active: { 131 | backgroundColor: Colors.darkBlue, 132 | }, 133 | thumb: { 134 | backgroundColor: Colors.darkPurple, 135 | }, 136 | }} 137 | xmax={gameConstants.MAX_TAKE_VAL} 138 | /> 139 | 140 |
141 |
142 | 145 |
146 |
147 |
148 | 149 | 158 |
159 | ); 160 | } 161 | -------------------------------------------------------------------------------- /src/frontend/pages/admin.tsx: -------------------------------------------------------------------------------- 1 | import { ReactElement, useEffect, useState } from 'react'; 2 | import { CSVLink } from 'react-csv'; 3 | import { API } from '../api-client'; 4 | import { useSetting } from '../hooks/useSetting'; 5 | import styles from '../styles/Admin.module.scss'; 6 | 7 | type AdminPageProps = { 8 | csvData: string; 9 | password: string; 10 | }; 11 | const ROUND = 'ROUND'; 12 | export const PLAYERS = 'PLAYERS'; 13 | const ROUND_TIMER = 'ROUND_TIMER'; 14 | const WAITING_ROOM_TIMER = 'WAITING_ROOM_TIMER'; 15 | 16 | function AdminPage({ csvData, password }: AdminPageProps): ReactElement { 17 | const settingRounds = useSetting(ROUND); 18 | const settingPlayers = useSetting(PLAYERS); 19 | const settingRoundTimer = useSetting(ROUND_TIMER); 20 | const settingWaitingRoomTimer = useSetting(WAITING_ROOM_TIMER); 21 | 22 | const [maxRounds, setMaxRounds] = useState(10); 23 | const [players, setPlayers] = useState(4); 24 | const [roundTimer, setRoundTimer] = useState(15); 25 | const [waitingRoomTimer, setWaitingRoomTimer] = useState(180); 26 | 27 | useEffect(() => { 28 | if (settingRounds) { 29 | setMaxRounds(settingRounds); 30 | } 31 | }, [settingRounds]); 32 | 33 | useEffect(() => { 34 | if (settingPlayers) { 35 | setPlayers(settingPlayers); 36 | } 37 | }, [settingPlayers]); 38 | 39 | useEffect(() => { 40 | if (settingRoundTimer) { 41 | setRoundTimer(settingRoundTimer); 42 | } 43 | }, [settingRoundTimer]); 44 | 45 | useEffect(() => { 46 | if (settingWaitingRoomTimer) { 47 | setWaitingRoomTimer(settingWaitingRoomTimer / 1000); 48 | } 49 | }, [settingWaitingRoomTimer]); 50 | 51 | const onRoundChange = (event) => { 52 | setMaxRounds(event.target.value); 53 | }; 54 | const onPlayersChange = (event) => { 55 | setPlayers(event.target.value); 56 | }; 57 | 58 | const onRoundTimerChange = (event) => { 59 | if (event.target.value < 25) { 60 | setRoundTimer(event.target.value); 61 | } 62 | }; 63 | const onWaitingRoomTimerChange = (event) => { 64 | setWaitingRoomTimer(event.target.value); 65 | }; 66 | 67 | const saveSetting = (setting: string, value: number) => { 68 | API.settings.update({ 69 | settingName: setting, 70 | value: value, 71 | password, 72 | }); 73 | }; 74 | 75 | return ( 76 |
77 |
78 | Max rounds: 79 |
80 | 86 | 92 |
93 |
94 |
95 | Players per game: 96 |
97 | 103 | 109 |
110 |
111 |
112 | Round length (in seconds): 113 |
114 | 120 | 126 |
127 |
128 |
129 | Waiting Room Length (in seconds): 130 |
131 | 137 | 145 |
146 |
147 | 152 |
153 | ); 154 | } 155 | 156 | export default function Admin(): ReactElement { 157 | const [data, setData] = useState(null); 158 | const [dataReturnedWithoutError, setDataReturnedWithoutError] = useState( 159 | false, 160 | ); 161 | const [password, setPassword] = useState(''); 162 | const [error, setError] = useState(null); 163 | const onPasswordChange = (event) => { 164 | setPassword(event.target.value); 165 | }; 166 | 167 | const onSubmitPassword = async () => { 168 | try { 169 | const exportData = await API.export.export({ password }); 170 | setData(exportData); 171 | setError(null); 172 | setDataReturnedWithoutError(true); 173 | } catch (e) { 174 | setError(e.response.data.message); 175 | setDataReturnedWithoutError(false); 176 | } 177 | }; 178 | 179 | return ( 180 |
181 | {dataReturnedWithoutError ? ( 182 | 183 | ) : ( 184 |
185 |
186 | Password 187 |
188 | 194 |
195 |
196 |
197 | 200 |
201 | {error &&
{error}
} 202 |
203 | )} 204 |
205 | ); 206 | } 207 | -------------------------------------------------------------------------------- /src/frontend/pages/game.tsx: -------------------------------------------------------------------------------- 1 | import Head from 'next/head'; 2 | import styles from '../styles/Game.module.scss'; 3 | import Slider from 'react-input-slider'; 4 | import Modal from 'react-modal'; 5 | import { 6 | buildStyles, 7 | CircularProgressbarWithChildren, 8 | } from 'react-circular-progressbar'; 9 | import 'react-circular-progressbar/dist/styles.css'; 10 | import React, { ReactElement, useEffect, useState } from 'react'; 11 | import Image from 'next/image'; 12 | import gameConstants from '../constants/gameConstants'; 13 | import GameModal from '../components/gameModal'; 14 | import Colors from '../constants/colorConstants'; 15 | import { API, API_URL } from '../api-client'; 16 | import { useRouter } from 'next/dist/client/router'; 17 | import { useEventSource } from '../hooks/useEventSource'; 18 | import { useSetting } from '../hooks/useSetting'; 19 | 20 | /** 21 | * TODO: Account for varying number of players in this view 22 | */ 23 | 24 | export default function Home(): ReactElement { 25 | /** 26 | * *STATE VARIABLES* 27 | * -takeVal: currently selected coins from slider that they are taking that turn 28 | * timeLeft: time left on the timer (starts at 10 seconds) 29 | * modalIsOpen: for modal open close 30 | * playerColor: value brought in from endpoint for this userID, will be one of ('Green', 'Yellow', 'Red', 'Blue) 31 | * playerCoins: total # of coins that this user has in this game 32 | * 33 | */ 34 | const [pointsRemaining, setPointsRemaining] = useState( 35 | gameConstants.INIT_TOTAL_COINS, 36 | ); 37 | const [takeVal, setTakeVal] = useState(gameConstants.MIN_TAKE_VAL); 38 | const TIMER_SECONDS = useSetting('ROUND_TIMER'); 39 | const [modalIsOpen, setModalIsOpen] = useState(false); 40 | const [playerPoints, setPlayerPoints] = useState( 41 | gameConstants.INIT_PLAYER_COINS, 42 | ); 43 | const [roundNumber, setRoundNumber] = useState(1); 44 | const router = useRouter(); 45 | const { gameId, playerId } = router.query; 46 | 47 | const [timeLeft, setTimeLeft] = useState(TIMER_SECONDS); 48 | 49 | const [takeComplete, setTakeComplete] = useState(false); 50 | const [gameOverModalIsOpen, setGameOverModalIsOpen] = useState( 51 | false, 52 | ); 53 | 54 | useEffect(() => { 55 | setTimeLeft(TIMER_SECONDS); // happens quickly 56 | }, [TIMER_SECONDS]); 57 | 58 | const gameUrl = `${API_URL}/game/sse?playerId=${playerId}&gameId=${gameId}`; 59 | 60 | useEventSource(gameUrl, (message) => { 61 | if (message.endMessage) { 62 | setGameOverModalIsOpen(true); 63 | } else if (message.newRound !== undefined) { 64 | setPointsRemaining(message.newRound.pointsRemaining); 65 | setRoundNumber(message.newRound.roundNumber); 66 | setTimeLeft(TIMER_SECONDS); 67 | setTakeComplete(false); 68 | } 69 | }); 70 | 71 | const pId = Number.parseInt(playerId as string); 72 | const handleTake = async () => { 73 | if (!takeComplete) { 74 | setTakeComplete(true); 75 | setPlayerPoints(playerPoints + takeVal); 76 | 77 | await API.game.take({ 78 | playerId: pId, 79 | howMany: takeVal, 80 | timeTaken: TIMER_SECONDS - timeLeft, 81 | roundNumber: roundNumber, 82 | }); 83 | } 84 | }; 85 | 86 | useEffect(() => { 87 | const interval = setInterval( 88 | () => setTimeLeft((timeLeft) => (timeLeft === 0 ? 0 : timeLeft - 1)), 89 | 1000, 90 | ); 91 | 92 | return () => clearInterval(interval); 93 | }, [timeLeft]); 94 | 95 | if (timeLeft === 0 && !takeComplete) { 96 | handleTake(); 97 | } 98 | 99 | return ( 100 |
101 | 102 | RDG NU | Game Page 103 | 104 | 105 | 106 |
107 | 108 | 112 |
113 |

Game

114 | {'Help setModalIsOpen(true)} 120 | /> 121 | setModalIsOpen(false)} 124 | style={{ 125 | content: { 126 | top: '20%', 127 | left: '60%', 128 | right: 'auto', 129 | bottom: 'auto', 130 | marginRight: '-50%', 131 | maxWidth: '500px', 132 | transform: 'translate(-50%, -50%)', 133 | }, 134 | }} 135 | contentLabel="Instructions Modal" 136 | > 137 | 138 | 139 |
140 | 141 |
142 | 143 |
146 | {timeLeft} 147 |
148 |
149 | 150 |
151 |
152 |

153 | You Are:{' '} 154 | 157 | {gameConstants.DEFAULT_COLOR}{' '} 158 | 159 |

160 |

161 | Your Total Points:{' '} 162 | {playerPoints} 163 |

164 |
165 |
166 | 174 | 175 | 176 | setTakeVal(x)} 180 | styles={{ 181 | active: { 182 | backgroundColor: Colors.darkBlue, 183 | }, 184 | thumb: { 185 | backgroundColor: Colors.darkPurple, 186 | }, 187 | }} 188 | xmax={gameConstants.MAX_TAKE_VAL} 189 | /> 190 | 191 |
192 |
193 | 200 |
201 |
202 |
203 | 204 | 213 |
214 | ); 215 | } 216 | 217 | // GAME TABLE HERE 218 | interface GameTableProps { 219 | pointsRemaining: number; 220 | } 221 | 222 | export const GameTable = ({ 223 | pointsRemaining, 224 | }: GameTableProps): ReactElement => { 225 | return ( 226 |
227 |
228 |
229 | green 235 |
236 |
237 | yellow 243 |
244 |
245 |
246 | 255 |
{pointsRemaining}
256 |
Points Left
257 |
258 |
259 |
260 |
261 | green 267 |
268 |
269 | yellow 275 |
276 |
277 |
278 | ); 279 | }; 280 | 281 | export const GameInstructionsModal = (): ReactElement => { 282 | return ( 283 |
284 |

Game Instructions

285 |
    286 |
  1. 287 | Select the amount of points you will take for each round (1-10) using 288 | the slider or the input box. 289 |
  2. 290 |
  3. Click the "Take" button to receive your points.
  4. 291 |
  5. 292 | Points will be replenished by 10% at the end of every round. Continue 293 | taking points until the game is over. 294 |
  6. 295 |
296 |
297 | ); 298 | }; 299 | -------------------------------------------------------------------------------- /src/frontend/public/pointsTaken.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /src/frontend/public/pointsReplenished.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | --------------------------------------------------------------------------------