diff --git a/backend/documentation.md b/backend/documentation.md index 88a5ece56cb0529c2b5ffe095158fac16d2d52c9..a5f4039d6237a14cc80548813fb7ae6da3139ec7 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 0000000000000000000000000000000000000000..80d05ab5c887dd7c28a7daf694dd753af775c0b2 --- /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 0000000000000000000000000000000000000000..a134e909d6926616f0cc3ff92e5181b9c3ae7407 --- /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 0000000000000000000000000000000000000000..a84a9e21bace51479f3133a97d257d5469af24ad --- /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 fda09101fced91f35ff5486a9cf5eee1db6a3715..fb9a0a995cc84a948ccf88a77b7fb19fbe85470e 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 0000000000000000000000000000000000000000..fc0fe74bbe37a039f00d1d9685f5405c2c4d09af --- /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 0000000000000000000000000000000000000000..46edf40f6606d9719f81da94586b2bde9ca473ea --- /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 0000000000000000000000000000000000000000..8611535b41ac36273c38028d515219a2581cb7f3 --- /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 0000000000000000000000000000000000000000..2c3acecff54164234e47f1a964993f18c3d3c977 --- /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; + } +}