├── 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 |
63 | Continue
64 |
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 |
55 | Select the amount of points you will take for each round (1-10)
56 | using the slider or the input box.
57 |
58 | Click the "Take" button to receive your points.
59 |
60 | Points will be replenished by 10% at the end of every round.
61 | Continue taking points until the game is over.
62 |
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 |
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 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
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 |
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 |
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 |
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 |
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 |
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 |
108 | Continue
109 |
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 |
63 | Click to Continue
64 |
65 |
66 |
67 |
Practice Rounds
68 |
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 |
143 | Take
144 |
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 | saveSetting(ROUND, maxRounds)}
89 | >
90 | Save
91 |
92 |
93 |
94 |
95 | Players per game:
96 |
97 |
103 | saveSetting(PLAYERS, players)}
106 | >
107 | Save
108 |
109 |
110 |
111 |
112 | Round length (in seconds):
113 |
114 |
120 | saveSetting(ROUND_TIMER, roundTimer)}
123 | >
124 | Save
125 |
126 |
127 |
128 |
129 | Waiting Room Length (in seconds):
130 |
131 |
137 |
140 | saveSetting(WAITING_ROOM_TIMER, waitingRoomTimer * 1000)
141 | }
142 | >
143 | Save
144 |
145 |
146 |
147 |
148 |
149 | Export Study Data
150 |
151 |
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 |
198 | Submit Password
199 |
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 |
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 |
198 | Take
199 |
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 |
235 |
236 |
237 |
243 |
244 |
245 |
246 |
255 | {pointsRemaining}
256 | Points Left
257 |
258 |
259 |
260 |
261 |
267 |
268 |
269 |
275 |
276 |
277 |
278 | );
279 | };
280 |
281 | export const GameInstructionsModal = (): ReactElement => {
282 | return (
283 |
284 |
Game Instructions
285 |
286 |
287 | Select the amount of points you will take for each round (1-10) using
288 | the slider or the input box.
289 |
290 | Click the "Take" button to receive your points.
291 |
292 | Points will be replenished by 10% at the end of every round. Continue
293 | taking points until the game is over.
294 |
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 |
--------------------------------------------------------------------------------