From 0e9f2d8d39b1e7456858ae222c5baaea1296bc67 Mon Sep 17 00:00:00 2001
From: Fredrik Fonn Hansen <fredrfha@stud.ntnu.no>
Date: Thu, 16 Mar 2023 13:54:21 +0100
Subject: [PATCH] Resolve "Create logic for lobbies in backend"

---
 backend/documentation.md         | 164 ++++++++++++++++++++++++++++++-
 backend/src/game/Game.ts         | 121 +++++++++++++++++++++++
 backend/src/game/Stats.ts        |  91 +++++++++++++++++
 backend/src/gameHandler.ts       |  71 +++++++++++++
 backend/src/index.ts             |  66 ++++++++++++-
 backend/src/interfaces/IGame.ts  |  28 ++++++
 backend/src/interfaces/ILobby.ts |  10 ++
 backend/src/interfaces/IStats.ts |  29 ++++++
 backend/src/lobby/Lobby.ts       |  47 +++++++++
 9 files changed, 621 insertions(+), 6 deletions(-)
 create mode 100644 backend/src/game/Game.ts
 create mode 100644 backend/src/game/Stats.ts
 create mode 100644 backend/src/gameHandler.ts
 create mode 100644 backend/src/interfaces/IGame.ts
 create mode 100644 backend/src/interfaces/ILobby.ts
 create mode 100644 backend/src/interfaces/IStats.ts
 create mode 100644 backend/src/lobby/Lobby.ts

diff --git a/backend/documentation.md b/backend/documentation.md
index 88a5ece..a5f4039 100644
--- a/backend/documentation.md
+++ b/backend/documentation.md
@@ -1,7 +1,18 @@
 # API Documentation
 
+This is an API documentation for an Express server that handles users, lobbies, and game state. The server uses Firebase Firestore for database storage.
+
+### Base URL
+
+> http://localhost:3000
+
 ### GET /user/
 
+Description: Returns a list of all user IDs
+Response:
+Status: 200 OK
+Body: Array of user IDs
+
 > - 204 No users found (DB is empty)
 > - 200 List of user-id's
 
@@ -10,16 +21,163 @@
 > - 404 User not found
 > - 200 User
 
-### GET /user/{username: string}
+### GET /user/{idOrUsername: string}
+
+Returns data for the user with the specified ID or username. If the parameter provided matches an ID, the response will contain data for that user. If the parameter matches a username, the response will contain data for the first user with that username found in the database.
+
+```json
+Status: 200 OK
+{
+  "username": "user1",
+  "highscore": 50,
+  "games": 10,
+  "wins": 5,
+  "losses": 5
+}
+```
 
 > - 404 User not found
 > - 200 User
 
-### POST /user/create/{id}
+### POST /user/create/{username: string}
+
+Creates a new user with the specified username. The username must be unique. If the username is already taken, the response will contain an error message.
 
 > - 409 User already exists
 > - 201 User created
 
 ### GET /highscores
-> - 204 No highscores found 
+
+Returns an array of the top 10 users with the highest scores, sorted in descending order.
+
+> - 204 No highscores found
 > - 200 List of users with highest score
+
+```json
+Status: 200 OK
+{
+  [
+    {
+        "username": "user1",
+        "highscore": 50,
+        "games": 10,
+        "wins": 5,
+        "losses": 5
+    },
+    {
+        "username": "user2",
+        "highscore": 40,
+        "games": 8,
+        "wins": 4,
+        "losses": 4
+    },
+    ...
+  ]
+}
+```
+
+### POST /lobby/:id
+
+Joins a lobby with the specified ID. If the lobby does not exist, a new lobby is created.
+
+```json
+POST /lobby/lobby1
+Body: { "userId": "user1" }
+```
+
+> - 201 Created
+
+### POST /lobby/:id/leave
+
+Leaves a lobby with the specified ID.
+
+Example request:
+
+```json
+POST /lobby/lobby1/leave
+Body: { "userId": "user1" }
+```
+
+### GET /game/:gameid/currentTurn
+
+Checks if it is the specified user's turn in the specified game. Returns the current game state if it is the user's turn.
+
+Example request:
+
+```json
+GET /game/game1/currentTurn
+Body: { "userName": "user1" }
+```
+
+Example response:
+
+```json
+Status: 200 OK
+{
+    "gameId": "12345678",
+    "gameStatus": false,
+    "currentTurn": 0,
+    "users": {
+        [
+            "User": {
+                "id": string;
+                "username": string;
+                "wins": number;
+                "losses": number;
+                "highscore": number;
+            },
+            "IStat": {
+                "position": [0,0];
+                "turretAngle": 0.5;
+                "isMirrored": true;
+                "health": 100;
+                "ammunition": 100;
+                "tankDirection": "left";
+                "tankType": "M107";
+                "score": 100;
+            },
+            ...
+        ]
+    },
+}
+```
+
+### POST /game/:gameid/move
+
+Updates the game state for the specified game with the new game state provided in the request body.
+
+Example request:
+
+```json
+POST /game/123456/move
+Body:
+{ "newGameState":
+    {
+        "gameId": "12345678",
+        "gameStatus": false,
+        "currentTurn": 0,
+        "users": {
+            [
+                "User": {
+                    "id": string;
+                    "username": string;
+                    "wins": number;
+                    "losses": number;
+                    "highscore": number;
+                },
+                "IStat": {
+                    "position": [0,0];
+                    "turretAngle": 0.5;
+                    "isMirrored": true;
+                    "health": 100;
+                    "ammunition": 100;
+                    "tankDirection": "left";
+                    "tankType": "M107";
+                    "score": 100;
+                },
+                ...
+            ]
+        },
+    }
+}
+```
diff --git a/backend/src/game/Game.ts b/backend/src/game/Game.ts
new file mode 100644
index 0000000..80d05ab
--- /dev/null
+++ b/backend/src/game/Game.ts
@@ -0,0 +1,121 @@
+// class for handling game logic
+
+import { User } from '../../types/User';
+import { IGame } from '../interfaces/IGame';
+import { ILobby } from '../interfaces/ILobby';
+import { IStats } from '../interfaces/IStats';
+import { Stats } from './Stats';
+
+export class Game implements IGame {
+  currentTurn: number;
+  lobby: ILobby;
+  gameStatus: boolean;
+  gameId: string;
+  users: [User, IStats][]; // left [0] and right [1] user
+
+  constructor(lobby: ILobby) {
+    this.lobby = lobby;
+    this.gameStatus = false; // game not finished
+    this.gameId = Math.random().toString(36);
+
+    // insert the lobby users into the game and create a new stats object for each user
+    this.users = lobby.getUsers().map((user) => [user, new Stats()]);
+
+    // set the stats for the left and right user
+    // todo add random tank type
+    this.users[0][1].setTankType('M107');
+    this.users[0][1].setTankDirection('left');
+    this.users[0][1].setIsMirrored(true); // this mirroring can also be done locally.
+
+    this.users[1][1].setTankType('M1A2');
+    this.users[1][1].setTankDirection('right');
+    this.users[1][1].setIsMirrored(false);
+
+    // make random number 0 or 1 to determine who starts
+    this.currentTurn = Math.round(Math.random());
+
+    this.notifyUsers(); // TODO: implement this method
+  }
+
+  setIsFinished(status: boolean): void {
+    this.gameStatus = status;
+  }
+
+  notifyUsers(): void {
+    throw new Error('Method not implemented.');
+  }
+
+  setGameStatus(status: boolean): void {
+    this.gameStatus = status;
+  }
+
+  getGameStatus(): boolean {
+    return this.gameStatus;
+  }
+
+  setScore(user: number, score: number): void {
+    if (!this.isValidUserNumber(user)) {
+      throw new Error('Unable to update score. Invalid userID.');
+    }
+    this.users[user][1].setScore(score);
+  }
+
+  getScore(user: number): number {
+    if (!this.isValidUserNumber(user)) {
+      throw new Error('Unable to get score. Invalid userID.');
+    }
+    return this.users[user][1].getScore();
+  }
+
+  getWinner(): User {
+    // return the the user with the highest score in stats
+    if (this.getGameStatus() == false) {
+      throw new Error('Game is not finished');
+    }
+    if (this.getScore(0) > this.getScore(1)) {
+      return this.users[0][0];
+    } else {
+      return this.users[1][0];
+    }
+  }
+  getLoser(): User {
+    // return the the user with the highest score in stats (duplicate code from getWinner)
+    if (this.getGameStatus() == false) {
+      throw new Error('Game is not finished');
+    }
+    if (this.getScore(0) < this.getScore(1)) {
+      return this.users[0][0];
+    } else {
+      return this.users[1][0];
+    }
+  }
+  calculateNextGameState(newGameStateJSON: string): void {
+    throw new Error('Method not implemented.');
+  }
+
+  getGameState(): IGame {
+    throw new Error('Method not implemented.');
+  }
+
+  isValidUserNumber(userNumber: number): boolean {
+    return userNumber === 0 || userNumber === 1;
+  }
+
+  getCurrentTurn(): number {
+    return this.currentTurn;
+  }
+
+  getCurrentTurnUser(): User {
+    return this.users[this.currentTurn][0];
+  }
+
+  // make json object of the game state (to send to clients)
+  getGameStateJSON(): string {
+    return JSON.stringify({
+      gameId: this.gameId,
+      gameStatus: this.gameStatus,
+      currentTurn: this.currentTurn,
+      users: this.users,
+    });
+  }
+}
diff --git a/backend/src/game/Stats.ts b/backend/src/game/Stats.ts
new file mode 100644
index 0000000..a134e90
--- /dev/null
+++ b/backend/src/game/Stats.ts
@@ -0,0 +1,91 @@
+import { IStats } from '../interfaces/IStats';
+
+export class Stats implements IStats {
+  position: number[][];
+  turretAngle: number;
+  isMirrored: boolean;
+  health: number;
+  ammunition: number;
+  tankDirection: string;
+  tankType: string;
+  score: number;
+
+  constructor() {
+    this.position = [
+      [0, 0],
+      [0, 0],
+    ];
+    this.turretAngle = 0;
+    this.health = 100;
+    this.ammunition = 100;
+    this.score = 0;
+    this.isMirrored = false;
+    this.tankDirection = 'left';
+    this.tankType = 'M107';
+  }
+
+  // create getters and setters
+  getPosition(): number[][] {
+    return this.position;
+  }
+
+  setPosition(position: number[][]): void {
+    this.position = position;
+  }
+
+  getTurretAngle(): number {
+    return this.turretAngle;
+  }
+
+  setTurretAngle(turretAngle: number): void {
+    this.turretAngle = turretAngle;
+  }
+
+  getIsMirrored(): boolean {
+    return this.isMirrored;
+  }
+
+  setIsMirrored(isMirrored: boolean): void {
+    this.isMirrored = isMirrored;
+  }
+
+  getHealth(): number {
+    return this.health;
+  }
+
+  setHealth(health: number): void {
+    this.health = health;
+  }
+
+  getAmmunition(): number {
+    return this.ammunition;
+  }
+
+  setAmmunition(ammunition: number): void {
+    this.ammunition = ammunition;
+  }
+
+  getTankDirection(): string {
+    return this.tankDirection;
+  }
+
+  setTankDirection(tankDirection: string): void {
+    this.tankDirection = tankDirection;
+  }
+
+  getTankType(): string {
+    return this.tankType;
+  }
+
+  setTankType(tankType: string): void {
+    this.tankType = tankType;
+  }
+
+  getScore(): number {
+    return this.score;
+  }
+
+  setScore(score: number): void {
+    this.score = score;
+  }
+}
diff --git a/backend/src/gameHandler.ts b/backend/src/gameHandler.ts
new file mode 100644
index 0000000..a84a9e2
--- /dev/null
+++ b/backend/src/gameHandler.ts
@@ -0,0 +1,71 @@
+// this module is responsible for handling the ongoing games.
+
+import { Game } from './game/Game';
+import { IGame } from './interfaces/IGame';
+import { ILobby } from './interfaces/ILobby';
+import { Lobby } from './lobby/Lobby';
+
+// this class is responsible for handling the ongoing games. and the lobbies.
+
+export class GameHandler {
+  private static instance: GameHandler;
+
+  static getInstance(): GameHandler {
+    if (!GameHandler.instance) {
+      GameHandler.instance = new GameHandler();
+    }
+    return GameHandler.instance;
+  }
+
+  private lobbies: ILobby[] = [];
+  private games: IGame[] = [];
+
+  constructor() {
+    this.lobbies = [];
+    this.games = [];
+  }
+
+  addLobby(lobby: ILobby) {
+    this.lobbies.push(lobby);
+  }
+
+  createGame(lobby: ILobby) {
+    const game = new Game(lobby);
+    this.addGame(game);
+    return game;
+  }
+
+  removeLobby(lobby: ILobby) {
+    this.lobbies = this.lobbies.filter((l) => l.id !== lobby.id);
+  }
+
+  addGame(game: IGame) {
+    this.games.push(game);
+  }
+
+  removeGame(game: IGame) {
+    this.games = this.games.filter((g) => g.gameId !== game.gameId);
+  }
+
+  getLobbies() {
+    return this.lobbies;
+  }
+
+  getGames() {
+    return this.games;
+  }
+
+  getGameById(gameId: string) {
+    return this.games.find((g) => g.gameId === gameId);
+  }
+
+  getLobbyById(id: string) {
+    return this.lobbies.find((l) => l.id === id);
+  }
+
+  createLobby() {
+    const lobby = new Lobby();
+    this.addLobby(lobby);
+    return lobby;
+  }
+}
diff --git a/backend/src/index.ts b/backend/src/index.ts
index fda0910..fb9a0a9 100644
--- a/backend/src/index.ts
+++ b/backend/src/index.ts
@@ -1,10 +1,13 @@
 import express from 'express';
 import path from 'path';
 import { User } from '../types/User';
+import { GameHandler } from './gameHandler';
 
 const app = express();
 const port = 3000;
 
+const gameHandler = new GameHandler(); // singleton ;)
+
 // const cors = require('cors');
 // app.use(cors());
 
@@ -87,9 +90,7 @@ app.post('/user/create/:username', async (req, res) => {
   }
 });
 
-
 // returns users with top 10 highscore
-
 app.get('/highscores', async (req, res) => {
   const usersRef = admin.firestore().collection('users');
   const querySnapshot = await usersRef.orderBy('highscore', 'desc').limit(10).get();
@@ -100,4 +101,63 @@ app.get('/highscores', async (req, res) => {
     const users = querySnapshot.docs.map((doc: any) => doc.data() as User);
     res.status(200).send(users);
   }
-})
+});
+
+// join a lobby with specific id
+app.post('/lobby/:id', async (req, res) => {
+  if (gameHandler.getLobbyById(req.params.id)) {
+    const lobby = gameHandler.getLobbyById(req.params.id);
+
+    if (!lobby) {
+      res.status(404).send('Lobby not found');
+      return;
+    }
+
+    lobby.addUser(req.body.userId);
+    gameHandler.createGame(lobby); // start game since 2 players are in lobby
+    res.status(200).send(lobby.getId());
+  } else {
+    // if lobby doesn't exist, create a new lobby
+    const lobby = gameHandler.createLobby();
+    lobby.addUser(req.body.userId);
+    res.status(201).send(lobby.getId());
+  }
+});
+
+// leave a lobby with specific id
+app.post('/lobby/:id/leave', async (req, res) => {
+  const lobby = gameHandler.getLobbyById(req.params.id);
+  lobby?.removeUser(req.body.userId);
+  // if lobby is empty, dispose
+  if (lobby?.getUsers().length === 0) {
+    gameHandler.removeLobby(lobby);
+  }
+  res.status(200).send(lobby?.getId());
+});
+
+// Polling endpoint to check if its your turn (sends username)
+app.get('/game/:gameid/currentTurn', async (req, res) => {
+  const game = gameHandler.getGameById(req.params.gameid);
+  const user = req.body.userName;
+  if (game) {
+    if (user === game.getCurrentTurnUser().username) {
+      // send the gamestate to the client
+      res.status(200).send(game.getGameStateJSON());
+    } else {
+      res.status(200).send('Not your turn');
+    }
+  } else {
+    res.status(404).send('Game not found (or not created yet because lack of opponent)');
+  }
+});
+
+// endpoint to send a move / gamestate
+app.post('/game/:gameid/move', async (req, res) => {
+  const game = gameHandler.getGameById(req.params.gameid);
+  if (game) {
+    game.calculateNextGameState(req.body.newGameState);
+    res.status(200).send('Move made');
+  } else {
+    res.status(404).send('Game not found');
+  }
+});
diff --git a/backend/src/interfaces/IGame.ts b/backend/src/interfaces/IGame.ts
new file mode 100644
index 0000000..fc0fe74
--- /dev/null
+++ b/backend/src/interfaces/IGame.ts
@@ -0,0 +1,28 @@
+import { User } from '../../types/User';
+import { ILobby } from './ILobby';
+import { IStats } from './IStats';
+
+// interface for a game instance
+
+export interface IGame {
+  notifyUsers(): void;
+
+  setIsFinished(status: boolean): void;
+
+  getWinner(): User;
+  getLoser(): User;
+  setScore(user: number, score: number): void;
+  getScore(user: number): number;
+
+  calculateNextGameState(newGameStateJSON: string): void;
+  getGameState(): IGame;
+
+  getCurrentTurnUser(): User;
+  getGameStateJSON(): string;
+
+  users: [User, IStats][];
+  currentTurn: number;
+  lobby: ILobby;
+  gameStatus: boolean;
+  gameId: string;
+}
diff --git a/backend/src/interfaces/ILobby.ts b/backend/src/interfaces/ILobby.ts
new file mode 100644
index 0000000..46edf40
--- /dev/null
+++ b/backend/src/interfaces/ILobby.ts
@@ -0,0 +1,10 @@
+import { User } from '../../types/User';
+
+export interface ILobby {
+  addUser(user: User): void;
+  removeUser(user: User): void;
+  getUsers(): User[];
+  id: string;
+  getId(): string;
+  // maybe add a relation to a game?
+}
diff --git a/backend/src/interfaces/IStats.ts b/backend/src/interfaces/IStats.ts
new file mode 100644
index 0000000..8611535
--- /dev/null
+++ b/backend/src/interfaces/IStats.ts
@@ -0,0 +1,29 @@
+import { User } from '../../types/User';
+
+export interface IStats {
+  position: number[][];
+  turretAngle: number;
+  isMirrored: boolean;
+  health: number;
+  ammunition: number;
+  tankDirection: string; // "left" or "right"
+  tankType: string;
+  score: number;
+
+  getPosition(): number[][];
+  setPosition(position: number[][]): void;
+  getTurretAngle(): number;
+  setTurretAngle(turretAngle: number): void;
+  getIsMirrored(): boolean;
+  setIsMirrored(isMirrored: boolean): void;
+  getHealth(): number;
+  setHealth(health: number): void;
+  getAmmunition(): number;
+  setAmmunition(ammunition: number): void;
+  getTankDirection(): string;
+  setTankDirection(tankDirection: string): void;
+  getTankType(): string;
+  setTankType(tankType: string): void;
+  getScore(): number;
+  setScore(score: number): void;
+}
diff --git a/backend/src/lobby/Lobby.ts b/backend/src/lobby/Lobby.ts
new file mode 100644
index 0000000..2c3acec
--- /dev/null
+++ b/backend/src/lobby/Lobby.ts
@@ -0,0 +1,47 @@
+import { User } from '../../types/User';
+import { IGame } from '../interfaces/IGame';
+import { ILobby } from '../interfaces/ILobby';
+
+/**
+ * A simple class for defining a lobby with a set of users.
+ * Used to create a game.
+ *
+ * @class Lobby
+ * @property {User[]} users - The users in the lobby
+ * @property {string} id - The id of the lobby
+ * @method addUser - Adds a user to the lobby
+ * @method removeUser - Removes a user from the lobby
+ * @method getUsers - Returns all users from the lobby
+ */
+
+export class Lobby implements ILobby {
+  constructor() {
+    this.users = [];
+    this.id =
+      Math.random().toString(36).substring(2, 15) +
+      Math.random().toString(36).substring(2, 15);
+  }
+  getId(): string {
+    return this.id;
+  }
+
+  private users: User[];
+  private game?: IGame;
+  id: string;
+
+  addUser(user: User) {
+    this.users.push(user);
+  }
+
+  removeUser(user: User) {
+    this.users = this.users.filter((u) => u.id !== user.id);
+  }
+
+  addGame(game: IGame) {
+    this.game = game;
+  }
+
+  getUsers() {
+    return this.users;
+  }
+}
-- 
GitLab