From 7c36fd1e696c0a5f7e286ad22d4d870a2309cee9 Mon Sep 17 00:00:00 2001 From: Haotian Yi Date: Fri, 29 Nov 2024 17:41:33 -0800 Subject: [PATCH 01/51] Room class for in-server storage --- backend/utility/room.js | 115 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 backend/utility/room.js diff --git a/backend/utility/room.js b/backend/utility/room.js new file mode 100644 index 0000000..f9c0747 --- /dev/null +++ b/backend/utility/room.js @@ -0,0 +1,115 @@ +const GAME_STATES = { + OPEN: "open", + PENDING: "pending", + STARTED: "started", + CLOSED: "closed" +} + +const ROOM_CODE_DIGITS = 4 // room code is 4-digits long + +// Room where games are held +// TODO: for now we store rooms in memory, and the total numbers +// of active rooms in any given moment is assumed to be < 10000 +class Room { + static gameId = 0 + + static codePair = {} + + constructor() { + this.id = ++Room.gameId // id + this.state = GAME_STATES.OPEN // game is by default open + this.players = new Set() // a set of players + + this.code = this.generateRoomCode() + Room.codePair[this.code] = this // generate room code & add to the dictionary + } + + // generate room code, which is a number ROOM_CODE_DIGITS long + generateRoomCode() { + var code = this.id % (10 ** ROOM_CODE_DIGITS) + while (code in Room.codePair) code = (code + 1) % (10 ** ROOM_CODE_DIGITS) + return String(code).padStart(ROOM_CODE_DIGITS, '0') + } + getRoomCode() { + return String(this.id).padStart(4, '0') + } + + // get a room + static getRoom(code) { + return Room.codePair[code] + } + + /* Functionalities */ + join(player) { + this.#addPlayer(player) + } + + exit(player) { + this.#removePlayer(player) + } + + saveGameRecords() { + // TODO: database-related method + return null + } + + // players + #addPlayer(player) { + this.players.add(player) + } + + #removePlayer(player) { + this.players.delete(player) + } + + /* Switch States */ + open() { + this.state = GAME_STATES.OPEN + } + + start() { + this.state = GAME_STATES.STARTED + } + + stop() { + this.state = GAME_STATES.PENDING + } + + close() { + this.state = GAME_STATES.CLOSED + delete Room.codePair[this.code] // reuse room ID + this.saveGameRecords() + + // TODO: it might be beneficial to clean room ID & save records separately, in bulk + } + + /* Clean-Up */ + // TODO: cleanup methods + + /* Getters */ + get playerCount() { + return this.players.size + } +} + +function test() { + let room1 = new Room() + let room2 = new Room() + let room3 = new Room() + let room4 = new Room() + + console.log(Room.codePair) + room3.close() + + let room5 = new Room() + + for (let i = 0; i < 996; i++) { + new Room() + } + + console.log(Room.codePair) +} + +module.exports = { + Room +} \ No newline at end of file From afe81d3808b26199cc5064c491bd065f4338c558 Mon Sep 17 00:00:00 2001 From: Haotian Yi Date: Fri, 29 Nov 2024 17:53:22 -0800 Subject: [PATCH 02/51] Basic functionality in creating rooms --- backend/routes/gameRouter.js | 24 +++++++++++++++++- frontend/src/PageRouter.js | 6 ++++- frontend/src/pages/CreateJoinRoom.js | 38 ++++++++++++++++++++++++++++ frontend/src/pages/FrontPage.js | 4 +-- 4 files changed, 68 insertions(+), 4 deletions(-) create mode 100644 frontend/src/pages/CreateJoinRoom.js diff --git a/backend/routes/gameRouter.js b/backend/routes/gameRouter.js index cd0ae6f..95bc0aa 100644 --- a/backend/routes/gameRouter.js +++ b/backend/routes/gameRouter.js @@ -5,6 +5,7 @@ const { getAccessToken } = require("../utility/tokenManager"); const DailyChallenge = require("../models/dailyChallenge"); const User = require("../models/userModel"); const mongoose = require("mongoose"); +const { Room } = require("../utility/room"); function normalizeDate(date) { const normalized = new Date(date); @@ -12,6 +13,27 @@ function normalizeDate(date) { return normalized; } +router.get("/room", async (req, res) => { + try { + + + + } catch (error) { + console.log("Error fetch room status:", error); + res.status(500).json({ error: "Internal server error." }); + } +}) + +router.post("/createRoom", async (req, res) => { + try { + let newRoom = new Room() + console.log("Created room with code", newRoom.getRoomCode()) + } catch(error) { + console.error("Error creating room:", error); + res.status(500).json({ error: "Internal server error." }); + } +}) + router.get("/dailyChallenge", async (req, res) => { try { @@ -42,7 +64,7 @@ router.post("/updateDailyScore", async(req, res) => { // if (user.dailyScore !== -1) { // return res.status(400).json({ error: "Daily challenge already completed." }); - // } + // } user.dailyScore = score; await user.save(); diff --git a/frontend/src/PageRouter.js b/frontend/src/PageRouter.js index 4fb4d68..0ee743e 100644 --- a/frontend/src/PageRouter.js +++ b/frontend/src/PageRouter.js @@ -6,6 +6,8 @@ import FrontPage from './pages/FrontPage.js'; import Game from './Game.js'; import DailyChallengePage from './pages/DailyChallenge.js'; import DailyChallengeLeaderboard from './pages/DailyChallengeLeaderboard.js'; +import CreateJoinRoom from './pages/CreateJoinRoom.js'; +import JoinRoom from './pages/JoinRoom.js'; const PageRouter = () => { @@ -13,7 +15,9 @@ const PageRouter = () => { } /> - } /> + } /> + } /> + } /> } /> } /> } /> diff --git a/frontend/src/pages/CreateJoinRoom.js b/frontend/src/pages/CreateJoinRoom.js new file mode 100644 index 0000000..4f5b80e --- /dev/null +++ b/frontend/src/pages/CreateJoinRoom.js @@ -0,0 +1,38 @@ +import React, { useState, useEffect } from "react"; +import { Link } from "react-router-dom"; + +const SERVER = process.env.REACT_APP_SERVER; + +const CreateJoinRoom = () => { + const createRoom = async (e) => { + try { + const response = await fetch(`${SERVER}/game/createRoom`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + }); + + if (!response.ok) { + throw new Error("Failed to create new rooms."); + } + + const data = await response.json(); + console.log("Room created:", data); + } catch (error) { + console.error("Error creating room:", error); + } + }; + + return ( +
+

MuseGuesser

+
+ Create Room + Join Room +
+
+ ); +} + +export default CreateJoinRoom; diff --git a/frontend/src/pages/FrontPage.js b/frontend/src/pages/FrontPage.js index da4c1aa..2d867dd 100644 --- a/frontend/src/pages/FrontPage.js +++ b/frontend/src/pages/FrontPage.js @@ -28,10 +28,10 @@ const FrontPage = () => {

MuseGuesser

{!doneDaily && loggedIn ? Daily Challenge : null} - {!loggedIn ?<> + {!loggedIn ?<> Sign In Sign Up - : Play Game} + : Play Game}
); From 582d3b01e08c69c8ad33481dda3ef55f3255020a Mon Sep 17 00:00:00 2001 From: Haotian Yi Date: Fri, 29 Nov 2024 17:58:50 -0800 Subject: [PATCH 03/51] Quicker room code access --- backend/utility/room.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/utility/room.js b/backend/utility/room.js index f9c0747..d689ea8 100644 --- a/backend/utility/room.js +++ b/backend/utility/room.js @@ -31,7 +31,7 @@ class Room { return String(code).padStart(ROOM_CODE_DIGITS, '0') } getRoomCode() { - return String(this.id).padStart(4, '0') + return this.code } // get a room From bf9222b7503a0499d0394bcb0489aa0e66903e4c Mon Sep 17 00:00:00 2001 From: Haotian Yi Date: Fri, 29 Nov 2024 18:47:50 -0800 Subject: [PATCH 04/51] Pass room code to frontend --- backend/routes/gameRouter.js | 1 + frontend/src/Game.js | 7 ++++++- frontend/src/pages/CreateJoinRoom.js | 8 ++++++-- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/backend/routes/gameRouter.js b/backend/routes/gameRouter.js index 95bc0aa..f39e528 100644 --- a/backend/routes/gameRouter.js +++ b/backend/routes/gameRouter.js @@ -28,6 +28,7 @@ router.post("/createRoom", async (req, res) => { try { let newRoom = new Room() console.log("Created room with code", newRoom.getRoomCode()) + res.status(200).json({code: newRoom.getRoomCode()}) } catch(error) { console.error("Error creating room:", error); res.status(500).json({ error: "Internal server error." }); diff --git a/frontend/src/Game.js b/frontend/src/Game.js index 650bce8..87342f6 100644 --- a/frontend/src/Game.js +++ b/frontend/src/Game.js @@ -1,5 +1,6 @@ import React, { useState } from "react"; import "./Game.css"; +import { useLocation } from "react-router-dom"; const SERVER = process.env.REACT_APP_SERVER; @@ -13,9 +14,12 @@ function Game() { const [points, setPoints] = useState(0); // state to hold points const [round, setRound] = useState(1); + const location = useLocation() + const { state } = location || {} + // play random previewurl const playRandomPreview = async () => { - + try { const response = await fetch( @@ -82,6 +86,7 @@ function Game() { return (
+

Room {state.code}

+ + + ); +}; + +const styles = { + container: { + display: "flex", + flexDirection: "column", + alignItems: "center", + justifyContent: "center", + height: "100vh", + backgroundColor: "#f5f5f5", + }, + inputContainer: { + margin: "10px 0", + textAlign: "center", + }, + input: { + width: "200px", + padding: "10px", + fontSize: "16px", + borderRadius: "5px", + border: "1px solid #ccc", + textAlign: "center", + marginTop: "5px", + }, + button: { + marginTop: "20px", + padding: "10px 20px", + fontSize: "16px", + borderRadius: "5px", + border: "none", + backgroundColor: "#4caf50", + color: "#fff", + cursor: "pointer", + transition: "background-color 0.3s ease", + }, +}; + +export default JoinRoom; From 456d1d9ba0440ee42de186a6be01804c4e404d48 Mon Sep 17 00:00:00 2001 From: Haotian Yi Date: Fri, 29 Nov 2024 19:51:55 -0800 Subject: [PATCH 06/51] Users correctly shown on each create/join --- backend/routes/gameRouter.js | 6 +++++- backend/utility/room.js | 5 +---- frontend/src/pages/CreateJoinRoom.js | 6 ++++++ frontend/src/pages/JoinRoom.js | 2 +- 4 files changed, 13 insertions(+), 6 deletions(-) diff --git a/backend/routes/gameRouter.js b/backend/routes/gameRouter.js index a27b60c..4615b91 100644 --- a/backend/routes/gameRouter.js +++ b/backend/routes/gameRouter.js @@ -25,7 +25,7 @@ router.post("/joinRoom", async (req, res) => { room.join(userId) // join // Response - res.status(200).json({ code: code, players: room.players }) + res.status(200).json({ code: code, players: Array.from(room.players) }) } else { res.status(400) // bad request, room doesn't exist @@ -41,7 +41,11 @@ router.post("/joinRoom", async (req, res) => { router.post("/createRoom", async (req, res) => { try { + const { userId } = req.body // Get room code & player infos + let newRoom = new Room() + newRoom.join(userId) + console.log("Created room with code", newRoom.getRoomCode()) res.status(200).json({code: newRoom.getRoomCode()}) } catch(error) { diff --git a/backend/utility/room.js b/backend/utility/room.js index d689ea8..d173042 100644 --- a/backend/utility/room.js +++ b/backend/utility/room.js @@ -41,6 +41,7 @@ class Room { /* Functionalities */ join(player) { + console.log("Player %s join room %d", player, this.id) this.#addPlayer(player) } @@ -103,10 +104,6 @@ function test() { let room5 = new Room() - for (let i = 0; i < 996; i++) { - new Room() - } - console.log(Room.codePair) } diff --git a/frontend/src/pages/CreateJoinRoom.js b/frontend/src/pages/CreateJoinRoom.js index 098ddfd..88d8b5c 100644 --- a/frontend/src/pages/CreateJoinRoom.js +++ b/frontend/src/pages/CreateJoinRoom.js @@ -4,6 +4,9 @@ import { Link, useNavigate } from "react-router-dom"; const SERVER = process.env.REACT_APP_SERVER; const CreateJoinRoom = () => { + const userData = localStorage.getItem("userData"); + const { userId } = JSON.parse(userData); + const navigate = useNavigate() const createRoom = async (e) => { @@ -13,6 +16,9 @@ const CreateJoinRoom = () => { headers: { "Content-Type": "application/json", }, + body: JSON.stringify({ + userId: userId + }) }); if (!response.ok) { diff --git a/frontend/src/pages/JoinRoom.js b/frontend/src/pages/JoinRoom.js index 67c7be7..4c1ff24 100644 --- a/frontend/src/pages/JoinRoom.js +++ b/frontend/src/pages/JoinRoom.js @@ -30,7 +30,7 @@ const JoinRoom = () => { } const data = await response.json(); - console.log("Joined room:", data.code); + console.log("Joined room:", data); navigate("/room/game", {state: {code: data.code}}) } catch (error) { From c16efaa706a0b68ce7da9c66add912ea2a022a27 Mon Sep 17 00:00:00 2001 From: Haotian Yi Date: Fri, 29 Nov 2024 20:24:31 -0800 Subject: [PATCH 07/51] Use Player class to record room game player info --- backend/routes/gameRouter.js | 9 +++++++-- backend/utility/player.js | 28 ++++++++++++++++++++++++++++ backend/utility/room.js | 2 +- 3 files changed, 36 insertions(+), 3 deletions(-) create mode 100644 backend/utility/player.js diff --git a/backend/routes/gameRouter.js b/backend/routes/gameRouter.js index 4615b91..bb67e43 100644 --- a/backend/routes/gameRouter.js +++ b/backend/routes/gameRouter.js @@ -6,6 +6,7 @@ const DailyChallenge = require("../models/dailyChallenge"); const User = require("../models/userModel"); const mongoose = require("mongoose"); const { Room } = require("../utility/room"); +const { Player } = require("../utility/player"); function normalizeDate(date) { const normalized = new Date(date); @@ -22,7 +23,9 @@ router.post("/joinRoom", async (req, res) => { let room = Room.getRoom(code) if (room !== undefined) { // room exists // TODO: Room operation - room.join(userId) // join + let player = new Player(userId) + + room.join(player) // join // Response res.status(200).json({ code: code, players: Array.from(room.players) }) @@ -44,7 +47,9 @@ router.post("/createRoom", async (req, res) => { const { userId } = req.body // Get room code & player infos let newRoom = new Room() - newRoom.join(userId) + + let player = new Player(userId) + newRoom.join(player) console.log("Created room with code", newRoom.getRoomCode()) res.status(200).json({code: newRoom.getRoomCode()}) diff --git a/backend/utility/player.js b/backend/utility/player.js new file mode 100644 index 0000000..a3d50e3 --- /dev/null +++ b/backend/utility/player.js @@ -0,0 +1,28 @@ +// Player model used in Rooms +class Player { // player is specific to room + constructor(userId) { + this.id = userId // unique identifier + this.score = 0 // starts with 0 score + this.lastUpdate = Date.now() // last update + } + + // Is the player expired? + isExpired(expiration = 1000 * 60 * 5) { // default to be 5 minutes + return Date.now() > this.lastUpdate + expiration + } + + // Add score + addScore(delta) { + this.score += delta + this.update() + } + + // Pulse - update lastUpdate + update() { + this.lastUpdate = Date.now() + } +} + +module.exports = { + Player +} \ No newline at end of file diff --git a/backend/utility/room.js b/backend/utility/room.js index d173042..c781e37 100644 --- a/backend/utility/room.js +++ b/backend/utility/room.js @@ -55,7 +55,7 @@ class Room { } // players - #addPlayer(player) { + #addPlayer(player) { // Player this.players.add(player) } From eddba55987389a79f6174ec2e25b0ef4a22d92c7 Mon Sep 17 00:00:00 2001 From: Haotian Yi Date: Fri, 29 Nov 2024 21:06:35 -0800 Subject: [PATCH 08/51] Questionset generation --- backend/utility/gameManager.js | 47 ++++++++++++++++++++++++++++++++++ backend/utility/questionSet.js | 26 +++++++++++++++++++ 2 files changed, 73 insertions(+) create mode 100644 backend/utility/gameManager.js create mode 100644 backend/utility/questionSet.js diff --git a/backend/utility/gameManager.js b/backend/utility/gameManager.js new file mode 100644 index 0000000..6c187e1 --- /dev/null +++ b/backend/utility/gameManager.js @@ -0,0 +1,47 @@ +import { SpotifyProxy } from "../controller/spotifyProxy"; +import { QuestionSet } from "./questionSet"; + +// Spotify proxy +const proxy = SpotifyProxy.getInstance() + +// Generate a question set +const generateQuestionSet = (genre='pop', choices=4) => { + let tracks = [] + let withPreview = [] + + while (withPreview.length == 0) { + tracks = proxy.recommendTracks(genre, choices*2) // increase the hit rate of preview_url + withPreview = tracks.filter((track) => track.preview_url) + } + + const correctTrack = withPreview + const incorrectTracks = tracks + .filter((track) => track !== correctTrack) + .sort(() => 0.5 - Math.random()) + .slice(0, choices - 1); + const correct = Math.floor(Math.random() * choices) + const finalTracks = [ + ...incorrectTracks.slice(0, correct), + correctTrack, + ...incorrectTracks.slice(correct) + ] + + let qs = new QuestionSet(finalTracks, correct) // generate question sets + return qs +} + +// Generate question sets +const generateQuestionSets = (count, genre='pop', choices=4) => { + let qss = [] + + for (let i = 0; i < count; i++) { + qss.push(generateQuestionSet(genre, choices)) + } + + return qss +} + +module.exports = { + generateQuestionSet, + generateQuestionSets +} \ No newline at end of file diff --git a/backend/utility/questionSet.js b/backend/utility/questionSet.js new file mode 100644 index 0000000..f696178 --- /dev/null +++ b/backend/utility/questionSet.js @@ -0,0 +1,26 @@ +class QuestionSet { // Class for multiple-choice with one answer + static QID = 0 // unique identifier + + constructor(tracks, correct) { + this.id = QID++ + + this.options = tracks.filter((track) => { + return { + name: track.name, + artist: track.artists[0].name + } + }) + + this.correct = correct + this.url = tracks[correct].preview_url // url + } + + // If choice is correct + isCorrect(choice) { + return choice == this.correct + } +} + +module.exports = { + QuestionSet +} \ No newline at end of file From 5d61c68f1ad21b25613daf8880f9bf9d2bd46dc5 Mon Sep 17 00:00:00 2001 From: Haotian Yi Date: Fri, 29 Nov 2024 21:25:50 -0800 Subject: [PATCH 09/51] Room model & saveGameRecords --- backend/models/roomModel.js | 22 ++++++++++++++++++++++ backend/utility/room.js | 27 ++++++++++++++++++++++----- 2 files changed, 44 insertions(+), 5 deletions(-) create mode 100644 backend/models/roomModel.js diff --git a/backend/models/roomModel.js b/backend/models/roomModel.js new file mode 100644 index 0000000..8cd6b0a --- /dev/null +++ b/backend/models/roomModel.js @@ -0,0 +1,22 @@ +const mongoose = require("mongoose"); + +const roomSchema = new mongoose.Schema({ + players: { + type: Array, + default: [] // { userId, score } + } +}); + +// Save game records +roomSchema.methods.saveGameRecords = async function (players) { + const playerRecords = players.map((player) => ({ + userId: player.id, + score: player.score + })); + + this.players = playerRecords; + + return await this.save(); +} + +module.exports = mongoose.model("RoomModel", roomSchema) \ No newline at end of file diff --git a/backend/utility/room.js b/backend/utility/room.js index c781e37..864a40c 100644 --- a/backend/utility/room.js +++ b/backend/utility/room.js @@ -1,3 +1,5 @@ +const RoomModel = require('../models/roomModel') + const GAME_STATES = { OPEN: "open", PENDING: "pending", @@ -18,10 +20,13 @@ class Room { constructor() { this.id = ++Room.gameId // id this.state = GAME_STATES.OPEN // game is by default open - this.players = new Set() // a set of players + this.players = new Set() // a set of players + this.questionSets = [] // questionSets + + this.model = new RoomModel() // database model this.code = this.generateRoomCode() - Room.codePair[this.code] = this // generate room code & add to the dictionary + Room.codePair[this.code] = this // generate room code & add to the dictionary } // generate room code, which is a number ROOM_CODE_DIGITS long @@ -49,9 +54,12 @@ class Room { this.#removePlayer(player) } - saveGameRecords() { - // TODO: database-related method - return null + // Save this room (results) to database + async saveGameRecords() { + const room = await RoomModel.findById(this.id) || new RoomModel() + await room.saveGameRecords(this.players) // save player records + + console.log("Room %s records saved.", this.getRoomCode()) } // players @@ -84,6 +92,15 @@ class Room { // TODO: it might be beneficial to clean room ID & save records separately, in bulk } + /* Question Sets */ + addQuesitonSet(questionSet) { + this.questionSets.push(questionSet) + } + + addQuestionSets(questionSets) { + this.questionSets.push(...questionSets) + } + /* Clean-Up */ // TODO: cleanup methods From 7dde81805bdd261e4e11c4a7aa93e1714e714992 Mon Sep 17 00:00:00 2001 From: Haotian Yi Date: Fri, 29 Nov 2024 21:42:41 -0800 Subject: [PATCH 10/51] Server-side verification --- backend/routes/gameRouter.js | 30 ++++++++++++++++++++++++++++++ backend/utility/room.js | 8 ++++---- 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/backend/routes/gameRouter.js b/backend/routes/gameRouter.js index bb67e43..37fe8cc 100644 --- a/backend/routes/gameRouter.js +++ b/backend/routes/gameRouter.js @@ -59,6 +59,36 @@ router.post("/createRoom", async (req, res) => { } }) +router.post("/submitAnswer", async (req, res) => { + try { + const { code, userId, qSet, choice, score } = req.body // Room code | userId | questionSet index | choice index + + // Verify answer + let room = Room.getRoom(code) + let questionSet = room.questionSets[qSet] // TODO: error handling + + let correct = questionSet.isCorrect(choice) + if (correct) { // correct answer + let player = room.players[userId] + player.addScore(score) + console.log("Room %s, player %s add %d score", code, userId, score) + } else { // wrong answer + let player = room.players[userId] + player.update() + console.log("Room %s, player %s wrong answer", code, userId) + } + + res.status(200).json({ + correct: correct, + score: score + }) + + } catch(error) { + console.error("Error verifying answer:", error); + res.status(500).json({ error: "Internal server error." }); + } +}) + router.get("/dailyChallenge", async (req, res) => { try { diff --git a/backend/utility/room.js b/backend/utility/room.js index 864a40c..ca411c2 100644 --- a/backend/utility/room.js +++ b/backend/utility/room.js @@ -20,7 +20,7 @@ class Room { constructor() { this.id = ++Room.gameId // id this.state = GAME_STATES.OPEN // game is by default open - this.players = new Set() // a set of players + this.players = new Map() // a map of players this.questionSets = [] // questionSets this.model = new RoomModel() // database model @@ -57,18 +57,18 @@ class Room { // Save this room (results) to database async saveGameRecords() { const room = await RoomModel.findById(this.id) || new RoomModel() - await room.saveGameRecords(this.players) // save player records + await room.saveGameRecords(this.players.values()) // save player records console.log("Room %s records saved.", this.getRoomCode()) } // players #addPlayer(player) { // Player - this.players.add(player) + this.players.set(player.id, player) } #removePlayer(player) { - this.players.delete(player) + this.players.delete(player.id) } /* Switch States */ From fb92c15b5e4df5e1d3c6808045b07637e3824548 Mon Sep 17 00:00:00 2001 From: Haotian Yi Date: Fri, 29 Nov 2024 21:49:43 -0800 Subject: [PATCH 11/51] Fixes a saveGameRecords bug --- backend/utility/room.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/backend/utility/room.js b/backend/utility/room.js index ca411c2..bbb430a 100644 --- a/backend/utility/room.js +++ b/backend/utility/room.js @@ -56,7 +56,7 @@ class Room { // Save this room (results) to database async saveGameRecords() { - const room = await RoomModel.findById(this.id) || new RoomModel() + const room = await RoomModel.findById(this.model._id) || new RoomModel() await room.saveGameRecords(this.players.values()) // save player records console.log("Room %s records saved.", this.getRoomCode()) @@ -119,11 +119,15 @@ function test() { console.log(Room.codePair) room3.close() + room4.saveGameRecords() + let room5 = new Room() console.log(Room.codePair) } +test() + module.exports = { Room } \ No newline at end of file From 95167989493e70c2918ff6d64f3e211364b7d745 Mon Sep 17 00:00:00 2001 From: Haotian Yi Date: Sat, 30 Nov 2024 09:28:28 -0800 Subject: [PATCH 12/51] Bug fixes related to saveGameRecords --- backend/db.js | 3 ++- backend/utility/room.js | 8 +++----- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/backend/db.js b/backend/db.js index fbfe8ea..568b769 100644 --- a/backend/db.js +++ b/backend/db.js @@ -17,5 +17,6 @@ mongoose.connect(process.env.MONGODB_URI) const Song = require('./models/songModel.js'); const User = require('./models/userModel.js'); const DailyChallenge = require('./models/dailyChallenge.js') +const RoomModel = require('./models/roomModel.js') -module.exports = {User, Song, DailyChallenge}; \ No newline at end of file +module.exports = {User, Song, DailyChallenge, RoomModel}; \ No newline at end of file diff --git a/backend/utility/room.js b/backend/utility/room.js index bbb430a..2a6810c 100644 --- a/backend/utility/room.js +++ b/backend/utility/room.js @@ -1,4 +1,4 @@ -const RoomModel = require('../models/roomModel') +const { RoomModel } = require("../db") const GAME_STATES = { OPEN: "open", @@ -56,9 +56,7 @@ class Room { // Save this room (results) to database async saveGameRecords() { - const room = await RoomModel.findById(this.model._id) || new RoomModel() - await room.saveGameRecords(this.players.values()) // save player records - + await this.model.saveGameRecords(Array.from(this.players.values())) // save player records console.log("Room %s records saved.", this.getRoomCode()) } @@ -126,7 +124,7 @@ function test() { console.log(Room.codePair) } -test() +// test() module.exports = { Room From f0d867b6266693a6331cc61c685ad7bc2fe5931e Mon Sep 17 00:00:00 2001 From: Haotian Yi Date: Sat, 30 Nov 2024 10:19:01 -0800 Subject: [PATCH 13/51] Refactored proxy organization to better accommodate change of providers --- backend/controller/dumbProxy.js | 77 +++++++++++++ backend/controller/providerProxy.js | 165 ++++++++++++++++++++++++++++ backend/controller/spotifyProxy.js | 92 +--------------- 3 files changed, 248 insertions(+), 86 deletions(-) create mode 100644 backend/controller/dumbProxy.js create mode 100644 backend/controller/providerProxy.js diff --git a/backend/controller/dumbProxy.js b/backend/controller/dumbProxy.js new file mode 100644 index 0000000..75042fe --- /dev/null +++ b/backend/controller/dumbProxy.js @@ -0,0 +1,77 @@ +const { getAccessToken } = require("../utility/tokenManager"); +const axios = require('axios'); +const ProviderProxy = require("./providerProxy"); + +/* Singleton class to manage all spotify-related Requests */ +class DumbProxy extends ProviderProxy { + static instance = null; + + // Singleton method + static getInstance() { + // Create a singleton instance if none available + if (DumbProxy.instance == null) { + DumbProxy.instance = new DumbProxy() + } + + return DumbProxy.instance + } + + // Get track with ID + async getTrack(trackId) { + console.log(`Trying to get track with id ${trackId}`) + + if (!(trackId in this.cache)) { + // request for specific track + console.log(`Requesting track with id ${trackId} from Spotify`) + try { + const accessToken = await getAccessToken(); + const response = await axios.get( + `https://api.spotify.com/v1/tracks/${trackId}`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + } + ).catch((error) => {console.log(error)}); + + this.addToCache(trackId, response.data) // add to cache + } catch (error) { + // TODO: error handling + throw error + } + } + + return this.cache[trackId] + } + + // Recommends tracks from genre + // TODO: for now we assume there is only one genre + async recommendTracks(genre, limit = 10) { + // Dumb proxy provides a list of randomly-generated tracks + let tracks = [] + for (let i = 0; i < limit; i++) { + let track = { + genre: genre, + artists: [ + { + "name": `Artist ${i + 1}`, + } + ], + name: `Track ${i + 1}`, + preview_url: `dumb` // empty + } + tracks.push(track) + } + return tracks + } +} + +async function test() { + let proxy = DumbProxy.getInstance() + let tracks = await proxy.recommendTracks("pop") + console.log(tracks) +} + +// test() + +module.exports = DumbProxy \ No newline at end of file diff --git a/backend/controller/providerProxy.js b/backend/controller/providerProxy.js new file mode 100644 index 0000000..e8d0fa4 --- /dev/null +++ b/backend/controller/providerProxy.js @@ -0,0 +1,165 @@ +const { getAccessToken } = require("../utility/tokenManager"); +const axios = require('axios') + +// Common filter functions +const PREVIEW_GENRE_FILTER_FAC = (genre) => (track) => track.preview_url && track.genre == genre +const PREVIEW_FILTER = (track) => track.preview_url +const ALL_FILTER = (track) => true + +/* Singleton class to manage all spotify-related Requests */ +class ProviderProxy { + static instance = null; + + constructor() { + this.cache = new Map() // dictionary - trackId: trackInfo + this.rateLimit = 0 // rate limit estimate + this.rlHandler = new CacheHandler() + } + + // Singleton method + static getInstance() { + // Create a singleton instance if none available + if (ProviderProxy.instance == null) { + ProviderProxy.instance = new ProviderProxy() + } + + return ProviderProxy.instance + } + + // Add to cache + addToCache(key, response) { + const cacheDuration = CacheHandler.cacheDuration(this.rateLimit) + + // Store response with a timeout to remove it after cacheDuration + const timeoutId = setTimeout(() => { + this.cache.delete(key) + console.log(`Cache expired for key: ${key}`) + }, cacheDuration) + + if (this.cache.has(key)) { + console.log(`Replaced track#${key}`) + } else console.log(`Added track#${key}`) + + console.log(JSON.stringify(response)) + this.cache.set(key, {response, timeoutId}) + } + + // Clear the cache + clearCache() { + // Clear all timeouts and cache entries + for (const [key, { timeoutId }] of this.cache.entries()) { + clearTimeout(timeoutId) + this.cache.delete(key) + } + } + + // Get track with ID + async getTrack(trackId) { + console.log(`Trying to get track with id ${trackId}`) + + if (!(trackId in this.cache)) { + // request for specific track + console.log(`Requesting track with id ${trackId} from Spotify`) + try { + const accessToken = await getAccessToken(); + const response = await axios.get( + `https://api.spotify.com/v1/tracks/${trackId}`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + } + ).catch((error) => {console.log(error)}); + + this.addToCache(trackId, response.data) // add to cache + } catch (error) { + // TODO: error handling + throw error + } + } + + return this.cache[trackId] + } + + // Recommends tracks from genre + // TODO: for now we assume there is only one genre + async recommendTracks(genre, limit = 10) { + try { + const accessToken = await getAccessToken(); + const response = await axios.get( + `https://api.spotify.com/v1/recommendations?seed_genres=${genre}&limit=${limit}`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + } + ).catch((error) => console.log(error)); + + console.log("Getting responses") + response.data.tracks.forEach(track => { + track.genre = genre // append genre to the song + this.addToCache(track.id, track) + }); + + return response.data.tracks + } catch (error) { + // TODO: error handling + throw error + } + + // TODO: Form recommendation from cache + // TODO: could race condition happens here? + } + + // (base) Get a random track with filter + // returns null if no match + getRandomTrackFilter(filter) { + const filteredValues = [...this.cache.values()] + .map(entry => entry.response) + .filter((value) => filter(value)) + if (filteredValues.length == 0) return null + return filteredValues[~~(Math.random() * filteredValues.length)] + } + + // Get a random track + getRandomTrack() { + return this.getRandomTrackFilter(ALL_FILTER) + } + + // Get a random track by genre + async getRandomTrackByGenre(genre) { + console.log("Fetching new recommendations of genre", genre) + await this.recommendTracks(genre) + + console.log("Filtering results") + const filter = PREVIEW_GENRE_FILTER_FAC(genre) + const result = this.getRandomTrackFilter(filter) + + return result + } +} + +// Factory to produce different strategies handling rate limits +const LOW_RATE_CACHE = 600000 // 10 minutes +const MID_RATE_CACHE = LOW_RATE_CACHE * 2 // 20 minutes +const HIGH_RATE_CACHE = MID_RATE_CACHE * 2 // 40 minutes + +class CacheHandler { + constructor() { + + } + + static cacheDuration(rateLimit) { + // TODO: strategies + return LOW_RATE_CACHE + } +} + +async function test() { + const proxy = ProviderProxy.getInstance() + console.log(proxy.getRandomTrackByGenre("pop")) + await proxy.recommendTracks("pop", 10) + console.log(proxy.getRandomTrackByGenre("pop")) +} + +module.exports = ProviderProxy \ No newline at end of file diff --git a/backend/controller/spotifyProxy.js b/backend/controller/spotifyProxy.js index 8dff0a9..aa2afef 100644 --- a/backend/controller/spotifyProxy.js +++ b/backend/controller/spotifyProxy.js @@ -1,21 +1,11 @@ const { getAccessToken } = require("../utility/tokenManager"); -const axios = require('axios') - -// Common filter functions -const PREVIEW_GENRE_FILTER_FAC = (genre) => (track) => track.preview_url && track.genre == genre -const PREVIEW_FILTER = (track) => track.preview_url -const ALL_FILTER = (track) => true +const axios = require('axios'); +const providerProxy = require("./providerProxy"); /* Singleton class to manage all spotify-related Requests */ -class SpotifyProxy { +class SpotifyProxy extends providerProxy { static instance = null; - constructor() { - this.cache = new Map() // dictionary - trackId: trackInfo - this.rateLimit = 0 // rate limit estimate - this.rlHandler = new CacheHandler() - } - // Singleton method static getInstance() { // Create a singleton instance if none available @@ -26,33 +16,6 @@ class SpotifyProxy { return SpotifyProxy.instance } - // Add to cache - addToCache(key, response) { - const cacheDuration = CacheHandler.cacheDuration(this.rateLimit) - - // Store response with a timeout to remove it after cacheDuration - const timeoutId = setTimeout(() => { - this.cache.delete(key) - console.log(`Cache expired for key: ${key}`) - }, cacheDuration) - - if (this.cache.has(key)) { - console.log(`Replaced track#${key}`) - } else console.log(`Added track#${key}`) - - console.log(JSON.stringify(response)) - this.cache.set(key, {response, timeoutId}) - } - - // Clear the cache - clearCache() { - // Clear all timeouts and cache entries - for (const [key, { timeoutId }] of this.cache.entries()) { - clearTimeout(timeoutId) - this.cache.delete(key) - } - } - // Get track with ID async getTrack(trackId) { console.log(`Trying to get track with id ${trackId}`) @@ -100,6 +63,8 @@ class SpotifyProxy { track.genre = genre // append genre to the song this.addToCache(track.id, track) }); + + return response.data.tracks } catch (error) { // TODO: error handling throw error @@ -108,49 +73,6 @@ class SpotifyProxy { // TODO: Form recommendation from cache // TODO: could race condition happens here? } - - // (base) Get a random track with filter - // returns null if no match - getRandomTrackFilter(filter) { - const filteredValues = [...this.cache.values()] - .map(entry => entry.response) - .filter((value) => filter(value)) - if (filteredValues.length == 0) return null - return filteredValues[~~(Math.random() * filteredValues.length)] - } - - // Get a random track - getRandomTrack() { - return this.getRandomTrackFilter(ALL_FILTER) - } - - // Get a random track by genre - async getRandomTrackByGenre(genre) { - console.log("Fetching new recommendations of genre", genre) - await this.recommendTracks(genre) - - console.log("Filtering results") - const filter = PREVIEW_GENRE_FILTER_FAC(genre) - const result = this.getRandomTrackFilter(filter) - - return result - } -} - -// Factory to produce different strategies handling rate limits -const LOW_RATE_CACHE = 600000 // 10 minutes -const MID_RATE_CACHE = LOW_RATE_CACHE * 2 // 20 minutes -const HIGH_RATE_CACHE = MID_RATE_CACHE * 2 // 40 minutes - -class CacheHandler { - constructor() { - - } - - static cacheDuration(rateLimit) { - // TODO: strategies - return LOW_RATE_CACHE - } } async function test() { @@ -160,6 +82,4 @@ async function test() { console.log(proxy.getRandomTrackByGenre("pop")) } -module.exports = { - SpotifyProxy -} \ No newline at end of file +module.exports = SpotifyProxy \ No newline at end of file From 95104a91b1445762e6f565290f4655f7408eb270 Mon Sep 17 00:00:00 2001 From: Haotian Yi Date: Sat, 30 Nov 2024 10:19:26 -0800 Subject: [PATCH 14/51] Bug fixes related to questionSet generation --- backend/utility/gameManager.js | 28 ++++++++++++++++++++-------- backend/utility/questionSet.js | 2 +- 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/backend/utility/gameManager.js b/backend/utility/gameManager.js index 6c187e1..3c11537 100644 --- a/backend/utility/gameManager.js +++ b/backend/utility/gameManager.js @@ -1,20 +1,24 @@ -import { SpotifyProxy } from "../controller/spotifyProxy"; -import { QuestionSet } from "./questionSet"; +const express = require('express') +require("dotenv").config() + +const DumbProxy = require("../controller/dumbProxy") +// import { SpotifyProxy } from "../controller/spotifyProxy"; +const { QuestionSet } = require("./questionSet") // Spotify proxy -const proxy = SpotifyProxy.getInstance() +const proxy = DumbProxy.getInstance() // Generate a question set -const generateQuestionSet = (genre='pop', choices=4) => { +const generateQuestionSet = async(genre='pop', choices=4) => { let tracks = [] let withPreview = [] while (withPreview.length == 0) { - tracks = proxy.recommendTracks(genre, choices*2) // increase the hit rate of preview_url + tracks = await proxy.recommendTracks(genre, choices*2) // increase the hit rate of preview_url withPreview = tracks.filter((track) => track.preview_url) } - const correctTrack = withPreview + const correctTrack = withPreview[0] const incorrectTracks = tracks .filter((track) => track !== correctTrack) .sort(() => 0.5 - Math.random()) @@ -31,16 +35,24 @@ const generateQuestionSet = (genre='pop', choices=4) => { } // Generate question sets -const generateQuestionSets = (count, genre='pop', choices=4) => { +const generateQuestionSets = async(count, genre='pop', choices=4) => { let qss = [] for (let i = 0; i < count; i++) { - qss.push(generateQuestionSet(genre, choices)) + qss.push(await generateQuestionSet(genre, choices)) } return qss } +const test = async() => { + let proxy = DumbProxy.getInstance() + let qss = await generateQuestionSets(5) + console.log(qss) +} + +// test() + module.exports = { generateQuestionSet, generateQuestionSets diff --git a/backend/utility/questionSet.js b/backend/utility/questionSet.js index f696178..d262602 100644 --- a/backend/utility/questionSet.js +++ b/backend/utility/questionSet.js @@ -2,7 +2,7 @@ class QuestionSet { // Class for multiple-choice with one answer static QID = 0 // unique identifier constructor(tracks, correct) { - this.id = QID++ + this.id = QuestionSet.QID++ this.options = tracks.filter((track) => { return { From 632617e3df13c19a91cba9aeac9fed4c51f54b77 Mon Sep 17 00:00:00 2001 From: Haotian Yi Date: Sat, 30 Nov 2024 10:24:51 -0800 Subject: [PATCH 15/51] Propagation of generated questionSets between front/back ends --- backend/routes/gameRouter.js | 20 ++++++++++++++++++-- frontend/src/Game.js | 17 ++++++++++++----- 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/backend/routes/gameRouter.js b/backend/routes/gameRouter.js index 37fe8cc..e15367e 100644 --- a/backend/routes/gameRouter.js +++ b/backend/routes/gameRouter.js @@ -7,6 +7,7 @@ const User = require("../models/userModel"); const mongoose = require("mongoose"); const { Room } = require("../utility/room"); const { Player } = require("../utility/player"); +const { generateQuestionSets } = require("../utility/gameManager"); function normalizeDate(date) { const normalized = new Date(date); @@ -28,7 +29,11 @@ router.post("/joinRoom", async (req, res) => { room.join(player) // join // Response - res.status(200).json({ code: code, players: Array.from(room.players) }) + res.status(200).json({ + code: code, + players: Array.from(room.players), + questionSets: room.questionSets + }) } else { res.status(400) // bad request, room doesn't exist @@ -46,13 +51,24 @@ router.post("/createRoom", async (req, res) => { try { const { userId } = req.body // Get room code & player infos + // Create room let newRoom = new Room() let player = new Player(userId) newRoom.join(player) console.log("Created room with code", newRoom.getRoomCode()) - res.status(200).json({code: newRoom.getRoomCode()}) + + // Generate question sets + newRoom.addQuestionSets(await generateQuestionSets(5)) // TODO: more options + + console.log("Question sets generated", newRoom.questionSets) + + // Response + res.status(200).json({ + code: newRoom.getRoomCode(), + questionSets: newRoom.questionSets + }) } catch(error) { console.error("Error creating room:", error); res.status(500).json({ error: "Internal server error." }); diff --git a/frontend/src/Game.js b/frontend/src/Game.js index 87342f6..65ed852 100644 --- a/frontend/src/Game.js +++ b/frontend/src/Game.js @@ -68,11 +68,7 @@ function Game() { } setIsPlaying(false); if (answer === correctAnswer) { - // exponential decay calculation for points - const timeElapsed = (Date.now() - startTime) / 1000; - const initialPoints = 100; // max possible points - const decayRate = 0.05; // how quickly points decrease over time - const earnedPoints = Math.round(initialPoints * Math.exp(-decayRate * timeElapsed)); + const earnedPoints = calculateScore() setPoints((prevPoints) => prevPoints + earnedPoints); // NEED TO MODIFY AFTER alert(`Correct! ${earnedPoints} points.`); @@ -83,6 +79,17 @@ function Game() { } }; + // Calculate local score when submitting answer + const calculateScore = () => { + // exponential decay calculation for points + const timeElapsed = (Date.now() - startTime) / 1000; + const initialPoints = 100; // max possible points + const decayRate = 0.05; // how quickly points decrease over time + const earnedPoints = Math.round(initialPoints * Math.exp(-decayRate * timeElapsed)); + + return earnedPoints + } + return (
From 5ac0fc3792a9a7296016f4e0fcbff37461c0b278 Mon Sep 17 00:00:00 2001 From: Haotian Yi Date: Sat, 30 Nov 2024 11:28:39 -0800 Subject: [PATCH 16/51] Added client submission & server verification --- backend/routes/gameRouter.js | 16 ++-- backend/utility/room.js | 2 + frontend/src/Game.js | 94 +++++++++++-------- frontend/src/pages/CreateJoinRoom.js | 2 +- .../src/pages/components/QuestionComponent.js | 73 ++++++++++++++ .../src/pages/styles/QuestionComponent.css | 0 6 files changed, 141 insertions(+), 46 deletions(-) create mode 100644 frontend/src/pages/components/QuestionComponent.js create mode 100644 frontend/src/pages/styles/QuestionComponent.css diff --git a/backend/routes/gameRouter.js b/backend/routes/gameRouter.js index e15367e..bec8a3b 100644 --- a/backend/routes/gameRouter.js +++ b/backend/routes/gameRouter.js @@ -29,10 +29,12 @@ router.post("/joinRoom", async (req, res) => { room.join(player) // join // Response + const updatedQuestionSets = newRoom.questionSets.map(({ correct, ...rest }) => rest); + res.status(200).json({ code: code, players: Array.from(room.players), - questionSets: room.questionSets + questionSets: updatedQuestionSets // hide correct answers }) } else { @@ -65,9 +67,11 @@ router.post("/createRoom", async (req, res) => { console.log("Question sets generated", newRoom.questionSets) // Response + const updatedQuestionSets = newRoom.questionSets.map(({ correct, ...rest }) => rest); + res.status(200).json({ code: newRoom.getRoomCode(), - questionSets: newRoom.questionSets + questionSets: updatedQuestionSets // hide correct answers }) } catch(error) { console.error("Error creating room:", error); @@ -77,19 +81,19 @@ router.post("/createRoom", async (req, res) => { router.post("/submitAnswer", async (req, res) => { try { - const { code, userId, qSet, choice, score } = req.body // Room code | userId | questionSet index | choice index + const { code, userId, idx, choice, score } = req.body // Room code | userId | questionSet index | choice index // Verify answer let room = Room.getRoom(code) - let questionSet = room.questionSets[qSet] // TODO: error handling + let questionSet = room.questionSets[idx] // TODO: error handling let correct = questionSet.isCorrect(choice) if (correct) { // correct answer - let player = room.players[userId] + let player = room.players.get(userId) player.addScore(score) console.log("Room %s, player %s add %d score", code, userId, score) } else { // wrong answer - let player = room.players[userId] + let player = room.players.get(userId) player.update() console.log("Room %s, player %s wrong answer", code, userId) } diff --git a/backend/utility/room.js b/backend/utility/room.js index 2a6810c..b6c79dc 100644 --- a/backend/utility/room.js +++ b/backend/utility/room.js @@ -27,6 +27,8 @@ class Room { this.code = this.generateRoomCode() Room.codePair[this.code] = this // generate room code & add to the dictionary + + console.log(Room.codePair) } // generate room code, which is a number ROOM_CODE_DIGITS long diff --git a/frontend/src/Game.js b/frontend/src/Game.js index 65ed852..00b9465 100644 --- a/frontend/src/Game.js +++ b/frontend/src/Game.js @@ -1,6 +1,7 @@ import React, { useState } from "react"; import "./Game.css"; import { useLocation } from "react-router-dom"; +import QuestionComponent from "./pages/components/QuestionComponent"; const SERVER = process.env.REACT_APP_SERVER; @@ -13,10 +14,14 @@ function Game() { const [startTime, setStartTime] = useState(null); // state to hold time const [points, setPoints] = useState(0); // state to hold points const [round, setRound] = useState(1); + const [idx, setIdx] = useState(0); // state to hold which question we are playing const location = useLocation() const { state } = location || {} + const userData = localStorage.getItem("userData"); + const { userId } = JSON.parse(userData); + // play random previewurl const playRandomPreview = async () => { @@ -79,10 +84,52 @@ function Game() { } }; + // Handle answer submission to the server for verification + const handleAnswerSubmission = async(idx, selectedOption, elapsedTime) => { + const score = calculateScore(elapsedTime) // local score + + try { + const response = await fetch(`${SERVER}/game/submitAnswer`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + code: state.code, // room code + userId: userId, // userId + idx: idx, // qSet index + choice: selectedOption, // index of selected option + score: score // local score + }) + }); + + if (!response.ok) { + throw new Error("Failed to submit answer."); + } + + const data = await response.json(); + console.log("Server feedback:", data); + + // Post-verifcation + if (data.correct === true) { // correct answer + setPoints(points + data.score) + alert("Correct answer!") + } else { + alert("Wrong answer!") + } + + // Increase current index + setIdx(idx + 1) + + } catch (error) { + console.error("Error submitting answers:", error); + } + } + // Calculate local score when submitting answer - const calculateScore = () => { + const calculateScore = (elapsedTime) => { // exponential decay calculation for points - const timeElapsed = (Date.now() - startTime) / 1000; + const timeElapsed = (elapsedTime) / 1000; const initialPoints = 100; // max possible points const decayRate = 0.05; // how quickly points decrease over time const earnedPoints = Math.round(initialPoints * Math.exp(-decayRate * timeElapsed)); @@ -94,44 +141,13 @@ function Game() {

Room {state.code}

- - - - - {isPlaying && answerOptions.length > 0 && ( -
-

Guess the Track:

- {answerOptions.map((option, index) => ( - - ))} -
- )}

Total Points: {points}

+ + {idx < state.questionSets.length ? ( + + ) : ( +

You have completed all the questions! Congratulations!

+ )}
); diff --git a/frontend/src/pages/CreateJoinRoom.js b/frontend/src/pages/CreateJoinRoom.js index 88d8b5c..d5b85e4 100644 --- a/frontend/src/pages/CreateJoinRoom.js +++ b/frontend/src/pages/CreateJoinRoom.js @@ -28,7 +28,7 @@ const CreateJoinRoom = () => { const data = await response.json(); console.log("Room created:", data); - navigate("game", {state: {code: data.code}}) + navigate("game", {state: {code: data.code, questionSets: data.questionSets}}) } catch (error) { console.error("Error creating room:", error); } diff --git a/frontend/src/pages/components/QuestionComponent.js b/frontend/src/pages/components/QuestionComponent.js new file mode 100644 index 0000000..bc991de --- /dev/null +++ b/frontend/src/pages/components/QuestionComponent.js @@ -0,0 +1,73 @@ +import React, { useState, useEffect } from "react"; + +const QuestionComponent = ({ idx, questionSet, onSubmit }) => { + const [selectedOption, setSelectedOption] = useState(null); + const [startTime, setStartTime] = useState(null); + const [elapsedTime, setElapsedTime] = useState(null); + + useEffect(() => { + // Record the start time when the component is mounted + setStartTime(Date.now()); + }, []); + + const handleOptionClick = (index) => { + console.log("index", index) + setSelectedOption(index); + + // Calculate the time taken and store it + const decisionTime = Date.now() - startTime; + setElapsedTime(decisionTime); + }; + + const handleSubmit = async() => { + if (selectedOption === null) { + alert("Please select an option before submitting."); + return; + } + + // Call the parent callback with the selected option and time taken + await onSubmit(idx, selectedOption, elapsedTime) + }; + + return ( +
+

Question {idx}

+

Choose the correct track based on the preview:

+
    + {questionSet.options.map((option, index) => ( +
  • + +
  • + ))} +
+ +
+ ); +}; + +export default QuestionComponent; diff --git a/frontend/src/pages/styles/QuestionComponent.css b/frontend/src/pages/styles/QuestionComponent.css new file mode 100644 index 0000000..e69de29 From bc5016982694dc4bd813c990edb0a67023d914e5 Mon Sep 17 00:00:00 2001 From: Haotian Yi Date: Fri, 29 Nov 2024 17:41:33 -0800 Subject: [PATCH 17/51] Room class for in-server storage --- backend/utility/room.js | 115 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 backend/utility/room.js diff --git a/backend/utility/room.js b/backend/utility/room.js new file mode 100644 index 0000000..f9c0747 --- /dev/null +++ b/backend/utility/room.js @@ -0,0 +1,115 @@ +const GAME_STATES = { + OPEN: "open", + PENDING: "pending", + STARTED: "started", + CLOSED: "closed" +} + +const ROOM_CODE_DIGITS = 4 // room code is 4-digits long + +// Room where games are held +// TODO: for now we store rooms in memory, and the total numbers +// of active rooms in any given moment is assumed to be < 10000 +class Room { + static gameId = 0 + + static codePair = {} + + constructor() { + this.id = ++Room.gameId // id + this.state = GAME_STATES.OPEN // game is by default open + this.players = new Set() // a set of players + + this.code = this.generateRoomCode() + Room.codePair[this.code] = this // generate room code & add to the dictionary + } + + // generate room code, which is a number ROOM_CODE_DIGITS long + generateRoomCode() { + var code = this.id % (10 ** ROOM_CODE_DIGITS) + while (code in Room.codePair) code = (code + 1) % (10 ** ROOM_CODE_DIGITS) + return String(code).padStart(ROOM_CODE_DIGITS, '0') + } + getRoomCode() { + return String(this.id).padStart(4, '0') + } + + // get a room + static getRoom(code) { + return Room.codePair[code] + } + + /* Functionalities */ + join(player) { + this.#addPlayer(player) + } + + exit(player) { + this.#removePlayer(player) + } + + saveGameRecords() { + // TODO: database-related method + return null + } + + // players + #addPlayer(player) { + this.players.add(player) + } + + #removePlayer(player) { + this.players.delete(player) + } + + /* Switch States */ + open() { + this.state = GAME_STATES.OPEN + } + + start() { + this.state = GAME_STATES.STARTED + } + + stop() { + this.state = GAME_STATES.PENDING + } + + close() { + this.state = GAME_STATES.CLOSED + delete Room.codePair[this.code] // reuse room ID + this.saveGameRecords() + + // TODO: it might be beneficial to clean room ID & save records separately, in bulk + } + + /* Clean-Up */ + // TODO: cleanup methods + + /* Getters */ + get playerCount() { + return this.players.size + } +} + +function test() { + let room1 = new Room() + let room2 = new Room() + let room3 = new Room() + let room4 = new Room() + + console.log(Room.codePair) + room3.close() + + let room5 = new Room() + + for (let i = 0; i < 996; i++) { + new Room() + } + + console.log(Room.codePair) +} + +module.exports = { + Room +} \ No newline at end of file From 8b73337ee686241647562f7181d231cd2a7d0818 Mon Sep 17 00:00:00 2001 From: Haotian Yi Date: Fri, 29 Nov 2024 17:53:22 -0800 Subject: [PATCH 18/51] Basic functionality in creating rooms --- backend/routes/gameRouter.js | 24 +++++++++++++++++- frontend/src/PageRouter.js | 6 ++++- frontend/src/pages/CreateJoinRoom.js | 38 ++++++++++++++++++++++++++++ frontend/src/pages/FrontPage.js | 6 ++--- 4 files changed, 69 insertions(+), 5 deletions(-) create mode 100644 frontend/src/pages/CreateJoinRoom.js diff --git a/backend/routes/gameRouter.js b/backend/routes/gameRouter.js index cd0ae6f..95bc0aa 100644 --- a/backend/routes/gameRouter.js +++ b/backend/routes/gameRouter.js @@ -5,6 +5,7 @@ const { getAccessToken } = require("../utility/tokenManager"); const DailyChallenge = require("../models/dailyChallenge"); const User = require("../models/userModel"); const mongoose = require("mongoose"); +const { Room } = require("../utility/room"); function normalizeDate(date) { const normalized = new Date(date); @@ -12,6 +13,27 @@ function normalizeDate(date) { return normalized; } +router.get("/room", async (req, res) => { + try { + + + + } catch (error) { + console.log("Error fetch room status:", error); + res.status(500).json({ error: "Internal server error." }); + } +}) + +router.post("/createRoom", async (req, res) => { + try { + let newRoom = new Room() + console.log("Created room with code", newRoom.getRoomCode()) + } catch(error) { + console.error("Error creating room:", error); + res.status(500).json({ error: "Internal server error." }); + } +}) + router.get("/dailyChallenge", async (req, res) => { try { @@ -42,7 +64,7 @@ router.post("/updateDailyScore", async(req, res) => { // if (user.dailyScore !== -1) { // return res.status(400).json({ error: "Daily challenge already completed." }); - // } + // } user.dailyScore = score; await user.save(); diff --git a/frontend/src/PageRouter.js b/frontend/src/PageRouter.js index d67a7c5..c0e6818 100644 --- a/frontend/src/PageRouter.js +++ b/frontend/src/PageRouter.js @@ -7,6 +7,8 @@ import Game from './Game.js'; import DailyChallengePage from './pages/DailyChallenge.js'; import DailyChallengeLeaderboard from './pages/DailyChallengeLeaderboard.js'; import MatchHistory from './pages/MatchHistory.js'; +import CreateJoinRoom from './pages/CreateJoinRoom.js'; +import JoinRoom from './pages/JoinRoom.js'; const PageRouter = () => { @@ -14,7 +16,9 @@ const PageRouter = () => { } /> - } /> + } /> + } /> + } /> } /> } /> } /> diff --git a/frontend/src/pages/CreateJoinRoom.js b/frontend/src/pages/CreateJoinRoom.js new file mode 100644 index 0000000..4f5b80e --- /dev/null +++ b/frontend/src/pages/CreateJoinRoom.js @@ -0,0 +1,38 @@ +import React, { useState, useEffect } from "react"; +import { Link } from "react-router-dom"; + +const SERVER = process.env.REACT_APP_SERVER; + +const CreateJoinRoom = () => { + const createRoom = async (e) => { + try { + const response = await fetch(`${SERVER}/game/createRoom`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + }); + + if (!response.ok) { + throw new Error("Failed to create new rooms."); + } + + const data = await response.json(); + console.log("Room created:", data); + } catch (error) { + console.error("Error creating room:", error); + } + }; + + return ( +
+

MuseGuesser

+
+ Create Room + Join Room +
+
+ ); +} + +export default CreateJoinRoom; diff --git a/frontend/src/pages/FrontPage.js b/frontend/src/pages/FrontPage.js index dbbc12e..0e68fea 100644 --- a/frontend/src/pages/FrontPage.js +++ b/frontend/src/pages/FrontPage.js @@ -28,12 +28,12 @@ const FrontPage = () => {

MuseGuesser

{!doneDaily && loggedIn ? Daily Challenge : null} - {!loggedIn ?<> + {!loggedIn ?<> Sign In Sign Up - : + : <> - Play Game + Play Game Match History }
From 009538bd684487a43f1501ea7661314b9701cacb Mon Sep 17 00:00:00 2001 From: Haotian Yi Date: Fri, 29 Nov 2024 17:58:50 -0800 Subject: [PATCH 19/51] Quicker room code access --- backend/utility/room.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/utility/room.js b/backend/utility/room.js index f9c0747..d689ea8 100644 --- a/backend/utility/room.js +++ b/backend/utility/room.js @@ -31,7 +31,7 @@ class Room { return String(code).padStart(ROOM_CODE_DIGITS, '0') } getRoomCode() { - return String(this.id).padStart(4, '0') + return this.code } // get a room From 783f4b578f1c8fa146c1b39b46a504d643fd0c18 Mon Sep 17 00:00:00 2001 From: Haotian Yi Date: Fri, 29 Nov 2024 18:47:50 -0800 Subject: [PATCH 20/51] Pass room code to frontend --- backend/routes/gameRouter.js | 1 + frontend/src/Game.js | 7 ++++++- frontend/src/pages/CreateJoinRoom.js | 8 ++++++-- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/backend/routes/gameRouter.js b/backend/routes/gameRouter.js index 95bc0aa..f39e528 100644 --- a/backend/routes/gameRouter.js +++ b/backend/routes/gameRouter.js @@ -28,6 +28,7 @@ router.post("/createRoom", async (req, res) => { try { let newRoom = new Room() console.log("Created room with code", newRoom.getRoomCode()) + res.status(200).json({code: newRoom.getRoomCode()}) } catch(error) { console.error("Error creating room:", error); res.status(500).json({ error: "Internal server error." }); diff --git a/frontend/src/Game.js b/frontend/src/Game.js index 650bce8..87342f6 100644 --- a/frontend/src/Game.js +++ b/frontend/src/Game.js @@ -1,5 +1,6 @@ import React, { useState } from "react"; import "./Game.css"; +import { useLocation } from "react-router-dom"; const SERVER = process.env.REACT_APP_SERVER; @@ -13,9 +14,12 @@ function Game() { const [points, setPoints] = useState(0); // state to hold points const [round, setRound] = useState(1); + const location = useLocation() + const { state } = location || {} + // play random previewurl const playRandomPreview = async () => { - + try { const response = await fetch( @@ -82,6 +86,7 @@ function Game() { return (
+

Room {state.code}

+ +
+ ); +}; + +const styles = { + container: { + display: "flex", + flexDirection: "column", + alignItems: "center", + justifyContent: "center", + height: "100vh", + backgroundColor: "#f5f5f5", + }, + inputContainer: { + margin: "10px 0", + textAlign: "center", + }, + input: { + width: "200px", + padding: "10px", + fontSize: "16px", + borderRadius: "5px", + border: "1px solid #ccc", + textAlign: "center", + marginTop: "5px", + }, + button: { + marginTop: "20px", + padding: "10px 20px", + fontSize: "16px", + borderRadius: "5px", + border: "none", + backgroundColor: "#4caf50", + color: "#fff", + cursor: "pointer", + transition: "background-color 0.3s ease", + }, +}; + +export default JoinRoom; From 919ae232d5d2877f59a70e9265c88c6eb2b25d55 Mon Sep 17 00:00:00 2001 From: Haotian Yi Date: Fri, 29 Nov 2024 19:51:55 -0800 Subject: [PATCH 22/51] Users correctly shown on each create/join --- backend/routes/gameRouter.js | 6 +++++- backend/utility/room.js | 5 +---- frontend/src/pages/CreateJoinRoom.js | 6 ++++++ frontend/src/pages/JoinRoom.js | 2 +- 4 files changed, 13 insertions(+), 6 deletions(-) diff --git a/backend/routes/gameRouter.js b/backend/routes/gameRouter.js index a27b60c..4615b91 100644 --- a/backend/routes/gameRouter.js +++ b/backend/routes/gameRouter.js @@ -25,7 +25,7 @@ router.post("/joinRoom", async (req, res) => { room.join(userId) // join // Response - res.status(200).json({ code: code, players: room.players }) + res.status(200).json({ code: code, players: Array.from(room.players) }) } else { res.status(400) // bad request, room doesn't exist @@ -41,7 +41,11 @@ router.post("/joinRoom", async (req, res) => { router.post("/createRoom", async (req, res) => { try { + const { userId } = req.body // Get room code & player infos + let newRoom = new Room() + newRoom.join(userId) + console.log("Created room with code", newRoom.getRoomCode()) res.status(200).json({code: newRoom.getRoomCode()}) } catch(error) { diff --git a/backend/utility/room.js b/backend/utility/room.js index d689ea8..d173042 100644 --- a/backend/utility/room.js +++ b/backend/utility/room.js @@ -41,6 +41,7 @@ class Room { /* Functionalities */ join(player) { + console.log("Player %s join room %d", player, this.id) this.#addPlayer(player) } @@ -103,10 +104,6 @@ function test() { let room5 = new Room() - for (let i = 0; i < 996; i++) { - new Room() - } - console.log(Room.codePair) } diff --git a/frontend/src/pages/CreateJoinRoom.js b/frontend/src/pages/CreateJoinRoom.js index 098ddfd..88d8b5c 100644 --- a/frontend/src/pages/CreateJoinRoom.js +++ b/frontend/src/pages/CreateJoinRoom.js @@ -4,6 +4,9 @@ import { Link, useNavigate } from "react-router-dom"; const SERVER = process.env.REACT_APP_SERVER; const CreateJoinRoom = () => { + const userData = localStorage.getItem("userData"); + const { userId } = JSON.parse(userData); + const navigate = useNavigate() const createRoom = async (e) => { @@ -13,6 +16,9 @@ const CreateJoinRoom = () => { headers: { "Content-Type": "application/json", }, + body: JSON.stringify({ + userId: userId + }) }); if (!response.ok) { diff --git a/frontend/src/pages/JoinRoom.js b/frontend/src/pages/JoinRoom.js index 67c7be7..4c1ff24 100644 --- a/frontend/src/pages/JoinRoom.js +++ b/frontend/src/pages/JoinRoom.js @@ -30,7 +30,7 @@ const JoinRoom = () => { } const data = await response.json(); - console.log("Joined room:", data.code); + console.log("Joined room:", data); navigate("/room/game", {state: {code: data.code}}) } catch (error) { From bf7247bcdcd48b031f3573c0b8ff9f2b3f834e62 Mon Sep 17 00:00:00 2001 From: Haotian Yi Date: Fri, 29 Nov 2024 20:24:31 -0800 Subject: [PATCH 23/51] Use Player class to record room game player info --- backend/routes/gameRouter.js | 9 +++++++-- backend/utility/player.js | 28 ++++++++++++++++++++++++++++ backend/utility/room.js | 2 +- 3 files changed, 36 insertions(+), 3 deletions(-) create mode 100644 backend/utility/player.js diff --git a/backend/routes/gameRouter.js b/backend/routes/gameRouter.js index 4615b91..bb67e43 100644 --- a/backend/routes/gameRouter.js +++ b/backend/routes/gameRouter.js @@ -6,6 +6,7 @@ const DailyChallenge = require("../models/dailyChallenge"); const User = require("../models/userModel"); const mongoose = require("mongoose"); const { Room } = require("../utility/room"); +const { Player } = require("../utility/player"); function normalizeDate(date) { const normalized = new Date(date); @@ -22,7 +23,9 @@ router.post("/joinRoom", async (req, res) => { let room = Room.getRoom(code) if (room !== undefined) { // room exists // TODO: Room operation - room.join(userId) // join + let player = new Player(userId) + + room.join(player) // join // Response res.status(200).json({ code: code, players: Array.from(room.players) }) @@ -44,7 +47,9 @@ router.post("/createRoom", async (req, res) => { const { userId } = req.body // Get room code & player infos let newRoom = new Room() - newRoom.join(userId) + + let player = new Player(userId) + newRoom.join(player) console.log("Created room with code", newRoom.getRoomCode()) res.status(200).json({code: newRoom.getRoomCode()}) diff --git a/backend/utility/player.js b/backend/utility/player.js new file mode 100644 index 0000000..a3d50e3 --- /dev/null +++ b/backend/utility/player.js @@ -0,0 +1,28 @@ +// Player model used in Rooms +class Player { // player is specific to room + constructor(userId) { + this.id = userId // unique identifier + this.score = 0 // starts with 0 score + this.lastUpdate = Date.now() // last update + } + + // Is the player expired? + isExpired(expiration = 1000 * 60 * 5) { // default to be 5 minutes + return Date.now() > this.lastUpdate + expiration + } + + // Add score + addScore(delta) { + this.score += delta + this.update() + } + + // Pulse - update lastUpdate + update() { + this.lastUpdate = Date.now() + } +} + +module.exports = { + Player +} \ No newline at end of file diff --git a/backend/utility/room.js b/backend/utility/room.js index d173042..c781e37 100644 --- a/backend/utility/room.js +++ b/backend/utility/room.js @@ -55,7 +55,7 @@ class Room { } // players - #addPlayer(player) { + #addPlayer(player) { // Player this.players.add(player) } From b991a5f56198650a67dfcf7f31278cc926962851 Mon Sep 17 00:00:00 2001 From: Haotian Yi Date: Fri, 29 Nov 2024 21:06:35 -0800 Subject: [PATCH 24/51] Questionset generation --- backend/utility/gameManager.js | 47 ++++++++++++++++++++++++++++++++++ backend/utility/questionSet.js | 26 +++++++++++++++++++ 2 files changed, 73 insertions(+) create mode 100644 backend/utility/gameManager.js create mode 100644 backend/utility/questionSet.js diff --git a/backend/utility/gameManager.js b/backend/utility/gameManager.js new file mode 100644 index 0000000..6c187e1 --- /dev/null +++ b/backend/utility/gameManager.js @@ -0,0 +1,47 @@ +import { SpotifyProxy } from "../controller/spotifyProxy"; +import { QuestionSet } from "./questionSet"; + +// Spotify proxy +const proxy = SpotifyProxy.getInstance() + +// Generate a question set +const generateQuestionSet = (genre='pop', choices=4) => { + let tracks = [] + let withPreview = [] + + while (withPreview.length == 0) { + tracks = proxy.recommendTracks(genre, choices*2) // increase the hit rate of preview_url + withPreview = tracks.filter((track) => track.preview_url) + } + + const correctTrack = withPreview + const incorrectTracks = tracks + .filter((track) => track !== correctTrack) + .sort(() => 0.5 - Math.random()) + .slice(0, choices - 1); + const correct = Math.floor(Math.random() * choices) + const finalTracks = [ + ...incorrectTracks.slice(0, correct), + correctTrack, + ...incorrectTracks.slice(correct) + ] + + let qs = new QuestionSet(finalTracks, correct) // generate question sets + return qs +} + +// Generate question sets +const generateQuestionSets = (count, genre='pop', choices=4) => { + let qss = [] + + for (let i = 0; i < count; i++) { + qss.push(generateQuestionSet(genre, choices)) + } + + return qss +} + +module.exports = { + generateQuestionSet, + generateQuestionSets +} \ No newline at end of file diff --git a/backend/utility/questionSet.js b/backend/utility/questionSet.js new file mode 100644 index 0000000..f696178 --- /dev/null +++ b/backend/utility/questionSet.js @@ -0,0 +1,26 @@ +class QuestionSet { // Class for multiple-choice with one answer + static QID = 0 // unique identifier + + constructor(tracks, correct) { + this.id = QID++ + + this.options = tracks.filter((track) => { + return { + name: track.name, + artist: track.artists[0].name + } + }) + + this.correct = correct + this.url = tracks[correct].preview_url // url + } + + // If choice is correct + isCorrect(choice) { + return choice == this.correct + } +} + +module.exports = { + QuestionSet +} \ No newline at end of file From fe46f1341d3528d618bd94e6d378771b226cb1c1 Mon Sep 17 00:00:00 2001 From: Haotian Yi Date: Fri, 29 Nov 2024 21:25:50 -0800 Subject: [PATCH 25/51] Room model & saveGameRecords --- backend/models/roomModel.js | 22 ++++++++++++++++++++++ backend/utility/room.js | 27 ++++++++++++++++++++++----- 2 files changed, 44 insertions(+), 5 deletions(-) create mode 100644 backend/models/roomModel.js diff --git a/backend/models/roomModel.js b/backend/models/roomModel.js new file mode 100644 index 0000000..8cd6b0a --- /dev/null +++ b/backend/models/roomModel.js @@ -0,0 +1,22 @@ +const mongoose = require("mongoose"); + +const roomSchema = new mongoose.Schema({ + players: { + type: Array, + default: [] // { userId, score } + } +}); + +// Save game records +roomSchema.methods.saveGameRecords = async function (players) { + const playerRecords = players.map((player) => ({ + userId: player.id, + score: player.score + })); + + this.players = playerRecords; + + return await this.save(); +} + +module.exports = mongoose.model("RoomModel", roomSchema) \ No newline at end of file diff --git a/backend/utility/room.js b/backend/utility/room.js index c781e37..864a40c 100644 --- a/backend/utility/room.js +++ b/backend/utility/room.js @@ -1,3 +1,5 @@ +const RoomModel = require('../models/roomModel') + const GAME_STATES = { OPEN: "open", PENDING: "pending", @@ -18,10 +20,13 @@ class Room { constructor() { this.id = ++Room.gameId // id this.state = GAME_STATES.OPEN // game is by default open - this.players = new Set() // a set of players + this.players = new Set() // a set of players + this.questionSets = [] // questionSets + + this.model = new RoomModel() // database model this.code = this.generateRoomCode() - Room.codePair[this.code] = this // generate room code & add to the dictionary + Room.codePair[this.code] = this // generate room code & add to the dictionary } // generate room code, which is a number ROOM_CODE_DIGITS long @@ -49,9 +54,12 @@ class Room { this.#removePlayer(player) } - saveGameRecords() { - // TODO: database-related method - return null + // Save this room (results) to database + async saveGameRecords() { + const room = await RoomModel.findById(this.id) || new RoomModel() + await room.saveGameRecords(this.players) // save player records + + console.log("Room %s records saved.", this.getRoomCode()) } // players @@ -84,6 +92,15 @@ class Room { // TODO: it might be beneficial to clean room ID & save records separately, in bulk } + /* Question Sets */ + addQuesitonSet(questionSet) { + this.questionSets.push(questionSet) + } + + addQuestionSets(questionSets) { + this.questionSets.push(...questionSets) + } + /* Clean-Up */ // TODO: cleanup methods From 67a6117eddaedd4f9958b80951ba011b2632a02b Mon Sep 17 00:00:00 2001 From: Haotian Yi Date: Fri, 29 Nov 2024 21:42:41 -0800 Subject: [PATCH 26/51] Server-side verification --- backend/routes/gameRouter.js | 30 ++++++++++++++++++++++++++++++ backend/utility/room.js | 8 ++++---- 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/backend/routes/gameRouter.js b/backend/routes/gameRouter.js index bb67e43..37fe8cc 100644 --- a/backend/routes/gameRouter.js +++ b/backend/routes/gameRouter.js @@ -59,6 +59,36 @@ router.post("/createRoom", async (req, res) => { } }) +router.post("/submitAnswer", async (req, res) => { + try { + const { code, userId, qSet, choice, score } = req.body // Room code | userId | questionSet index | choice index + + // Verify answer + let room = Room.getRoom(code) + let questionSet = room.questionSets[qSet] // TODO: error handling + + let correct = questionSet.isCorrect(choice) + if (correct) { // correct answer + let player = room.players[userId] + player.addScore(score) + console.log("Room %s, player %s add %d score", code, userId, score) + } else { // wrong answer + let player = room.players[userId] + player.update() + console.log("Room %s, player %s wrong answer", code, userId) + } + + res.status(200).json({ + correct: correct, + score: score + }) + + } catch(error) { + console.error("Error verifying answer:", error); + res.status(500).json({ error: "Internal server error." }); + } +}) + router.get("/dailyChallenge", async (req, res) => { try { diff --git a/backend/utility/room.js b/backend/utility/room.js index 864a40c..ca411c2 100644 --- a/backend/utility/room.js +++ b/backend/utility/room.js @@ -20,7 +20,7 @@ class Room { constructor() { this.id = ++Room.gameId // id this.state = GAME_STATES.OPEN // game is by default open - this.players = new Set() // a set of players + this.players = new Map() // a map of players this.questionSets = [] // questionSets this.model = new RoomModel() // database model @@ -57,18 +57,18 @@ class Room { // Save this room (results) to database async saveGameRecords() { const room = await RoomModel.findById(this.id) || new RoomModel() - await room.saveGameRecords(this.players) // save player records + await room.saveGameRecords(this.players.values()) // save player records console.log("Room %s records saved.", this.getRoomCode()) } // players #addPlayer(player) { // Player - this.players.add(player) + this.players.set(player.id, player) } #removePlayer(player) { - this.players.delete(player) + this.players.delete(player.id) } /* Switch States */ From 30c786cc411f32032767544b22491872c904ef30 Mon Sep 17 00:00:00 2001 From: Haotian Yi Date: Fri, 29 Nov 2024 21:49:43 -0800 Subject: [PATCH 27/51] Fixes a saveGameRecords bug --- backend/utility/room.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/backend/utility/room.js b/backend/utility/room.js index ca411c2..bbb430a 100644 --- a/backend/utility/room.js +++ b/backend/utility/room.js @@ -56,7 +56,7 @@ class Room { // Save this room (results) to database async saveGameRecords() { - const room = await RoomModel.findById(this.id) || new RoomModel() + const room = await RoomModel.findById(this.model._id) || new RoomModel() await room.saveGameRecords(this.players.values()) // save player records console.log("Room %s records saved.", this.getRoomCode()) @@ -119,11 +119,15 @@ function test() { console.log(Room.codePair) room3.close() + room4.saveGameRecords() + let room5 = new Room() console.log(Room.codePair) } +test() + module.exports = { Room } \ No newline at end of file From 6485a7ac987e0147eb0e9e52da050148a07655b3 Mon Sep 17 00:00:00 2001 From: Haotian Yi Date: Sat, 30 Nov 2024 09:28:28 -0800 Subject: [PATCH 28/51] Bug fixes related to saveGameRecords --- backend/db.js | 3 ++- backend/utility/room.js | 8 +++----- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/backend/db.js b/backend/db.js index da81fc7..bbbabd5 100644 --- a/backend/db.js +++ b/backend/db.js @@ -18,5 +18,6 @@ const Song = require('./models/songModel.js'); const User = require('./models/userModel.js'); const DailyChallenge = require('./models/dailyChallenge.js') const Match = require('./models/matchModel.js'); +const RoomModel = require('./models/roomModel.js') -module.exports = {User, Song, DailyChallenge, Match}; \ No newline at end of file +module.exports = {User, Song, DailyChallenge, Match, RoomModel}; diff --git a/backend/utility/room.js b/backend/utility/room.js index bbb430a..2a6810c 100644 --- a/backend/utility/room.js +++ b/backend/utility/room.js @@ -1,4 +1,4 @@ -const RoomModel = require('../models/roomModel') +const { RoomModel } = require("../db") const GAME_STATES = { OPEN: "open", @@ -56,9 +56,7 @@ class Room { // Save this room (results) to database async saveGameRecords() { - const room = await RoomModel.findById(this.model._id) || new RoomModel() - await room.saveGameRecords(this.players.values()) // save player records - + await this.model.saveGameRecords(Array.from(this.players.values())) // save player records console.log("Room %s records saved.", this.getRoomCode()) } @@ -126,7 +124,7 @@ function test() { console.log(Room.codePair) } -test() +// test() module.exports = { Room From 3bea0f5c62ca93b9974bc85d6788c5fd6a7ca61e Mon Sep 17 00:00:00 2001 From: Haotian Yi Date: Sat, 30 Nov 2024 10:19:01 -0800 Subject: [PATCH 29/51] Refactored proxy organization to better accommodate change of providers --- backend/controller/dumbProxy.js | 77 +++++++++++++ backend/controller/providerProxy.js | 165 ++++++++++++++++++++++++++++ backend/controller/spotifyProxy.js | 92 +--------------- 3 files changed, 248 insertions(+), 86 deletions(-) create mode 100644 backend/controller/dumbProxy.js create mode 100644 backend/controller/providerProxy.js diff --git a/backend/controller/dumbProxy.js b/backend/controller/dumbProxy.js new file mode 100644 index 0000000..75042fe --- /dev/null +++ b/backend/controller/dumbProxy.js @@ -0,0 +1,77 @@ +const { getAccessToken } = require("../utility/tokenManager"); +const axios = require('axios'); +const ProviderProxy = require("./providerProxy"); + +/* Singleton class to manage all spotify-related Requests */ +class DumbProxy extends ProviderProxy { + static instance = null; + + // Singleton method + static getInstance() { + // Create a singleton instance if none available + if (DumbProxy.instance == null) { + DumbProxy.instance = new DumbProxy() + } + + return DumbProxy.instance + } + + // Get track with ID + async getTrack(trackId) { + console.log(`Trying to get track with id ${trackId}`) + + if (!(trackId in this.cache)) { + // request for specific track + console.log(`Requesting track with id ${trackId} from Spotify`) + try { + const accessToken = await getAccessToken(); + const response = await axios.get( + `https://api.spotify.com/v1/tracks/${trackId}`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + } + ).catch((error) => {console.log(error)}); + + this.addToCache(trackId, response.data) // add to cache + } catch (error) { + // TODO: error handling + throw error + } + } + + return this.cache[trackId] + } + + // Recommends tracks from genre + // TODO: for now we assume there is only one genre + async recommendTracks(genre, limit = 10) { + // Dumb proxy provides a list of randomly-generated tracks + let tracks = [] + for (let i = 0; i < limit; i++) { + let track = { + genre: genre, + artists: [ + { + "name": `Artist ${i + 1}`, + } + ], + name: `Track ${i + 1}`, + preview_url: `dumb` // empty + } + tracks.push(track) + } + return tracks + } +} + +async function test() { + let proxy = DumbProxy.getInstance() + let tracks = await proxy.recommendTracks("pop") + console.log(tracks) +} + +// test() + +module.exports = DumbProxy \ No newline at end of file diff --git a/backend/controller/providerProxy.js b/backend/controller/providerProxy.js new file mode 100644 index 0000000..e8d0fa4 --- /dev/null +++ b/backend/controller/providerProxy.js @@ -0,0 +1,165 @@ +const { getAccessToken } = require("../utility/tokenManager"); +const axios = require('axios') + +// Common filter functions +const PREVIEW_GENRE_FILTER_FAC = (genre) => (track) => track.preview_url && track.genre == genre +const PREVIEW_FILTER = (track) => track.preview_url +const ALL_FILTER = (track) => true + +/* Singleton class to manage all spotify-related Requests */ +class ProviderProxy { + static instance = null; + + constructor() { + this.cache = new Map() // dictionary - trackId: trackInfo + this.rateLimit = 0 // rate limit estimate + this.rlHandler = new CacheHandler() + } + + // Singleton method + static getInstance() { + // Create a singleton instance if none available + if (ProviderProxy.instance == null) { + ProviderProxy.instance = new ProviderProxy() + } + + return ProviderProxy.instance + } + + // Add to cache + addToCache(key, response) { + const cacheDuration = CacheHandler.cacheDuration(this.rateLimit) + + // Store response with a timeout to remove it after cacheDuration + const timeoutId = setTimeout(() => { + this.cache.delete(key) + console.log(`Cache expired for key: ${key}`) + }, cacheDuration) + + if (this.cache.has(key)) { + console.log(`Replaced track#${key}`) + } else console.log(`Added track#${key}`) + + console.log(JSON.stringify(response)) + this.cache.set(key, {response, timeoutId}) + } + + // Clear the cache + clearCache() { + // Clear all timeouts and cache entries + for (const [key, { timeoutId }] of this.cache.entries()) { + clearTimeout(timeoutId) + this.cache.delete(key) + } + } + + // Get track with ID + async getTrack(trackId) { + console.log(`Trying to get track with id ${trackId}`) + + if (!(trackId in this.cache)) { + // request for specific track + console.log(`Requesting track with id ${trackId} from Spotify`) + try { + const accessToken = await getAccessToken(); + const response = await axios.get( + `https://api.spotify.com/v1/tracks/${trackId}`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + } + ).catch((error) => {console.log(error)}); + + this.addToCache(trackId, response.data) // add to cache + } catch (error) { + // TODO: error handling + throw error + } + } + + return this.cache[trackId] + } + + // Recommends tracks from genre + // TODO: for now we assume there is only one genre + async recommendTracks(genre, limit = 10) { + try { + const accessToken = await getAccessToken(); + const response = await axios.get( + `https://api.spotify.com/v1/recommendations?seed_genres=${genre}&limit=${limit}`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + } + ).catch((error) => console.log(error)); + + console.log("Getting responses") + response.data.tracks.forEach(track => { + track.genre = genre // append genre to the song + this.addToCache(track.id, track) + }); + + return response.data.tracks + } catch (error) { + // TODO: error handling + throw error + } + + // TODO: Form recommendation from cache + // TODO: could race condition happens here? + } + + // (base) Get a random track with filter + // returns null if no match + getRandomTrackFilter(filter) { + const filteredValues = [...this.cache.values()] + .map(entry => entry.response) + .filter((value) => filter(value)) + if (filteredValues.length == 0) return null + return filteredValues[~~(Math.random() * filteredValues.length)] + } + + // Get a random track + getRandomTrack() { + return this.getRandomTrackFilter(ALL_FILTER) + } + + // Get a random track by genre + async getRandomTrackByGenre(genre) { + console.log("Fetching new recommendations of genre", genre) + await this.recommendTracks(genre) + + console.log("Filtering results") + const filter = PREVIEW_GENRE_FILTER_FAC(genre) + const result = this.getRandomTrackFilter(filter) + + return result + } +} + +// Factory to produce different strategies handling rate limits +const LOW_RATE_CACHE = 600000 // 10 minutes +const MID_RATE_CACHE = LOW_RATE_CACHE * 2 // 20 minutes +const HIGH_RATE_CACHE = MID_RATE_CACHE * 2 // 40 minutes + +class CacheHandler { + constructor() { + + } + + static cacheDuration(rateLimit) { + // TODO: strategies + return LOW_RATE_CACHE + } +} + +async function test() { + const proxy = ProviderProxy.getInstance() + console.log(proxy.getRandomTrackByGenre("pop")) + await proxy.recommendTracks("pop", 10) + console.log(proxy.getRandomTrackByGenre("pop")) +} + +module.exports = ProviderProxy \ No newline at end of file diff --git a/backend/controller/spotifyProxy.js b/backend/controller/spotifyProxy.js index 8dff0a9..aa2afef 100644 --- a/backend/controller/spotifyProxy.js +++ b/backend/controller/spotifyProxy.js @@ -1,21 +1,11 @@ const { getAccessToken } = require("../utility/tokenManager"); -const axios = require('axios') - -// Common filter functions -const PREVIEW_GENRE_FILTER_FAC = (genre) => (track) => track.preview_url && track.genre == genre -const PREVIEW_FILTER = (track) => track.preview_url -const ALL_FILTER = (track) => true +const axios = require('axios'); +const providerProxy = require("./providerProxy"); /* Singleton class to manage all spotify-related Requests */ -class SpotifyProxy { +class SpotifyProxy extends providerProxy { static instance = null; - constructor() { - this.cache = new Map() // dictionary - trackId: trackInfo - this.rateLimit = 0 // rate limit estimate - this.rlHandler = new CacheHandler() - } - // Singleton method static getInstance() { // Create a singleton instance if none available @@ -26,33 +16,6 @@ class SpotifyProxy { return SpotifyProxy.instance } - // Add to cache - addToCache(key, response) { - const cacheDuration = CacheHandler.cacheDuration(this.rateLimit) - - // Store response with a timeout to remove it after cacheDuration - const timeoutId = setTimeout(() => { - this.cache.delete(key) - console.log(`Cache expired for key: ${key}`) - }, cacheDuration) - - if (this.cache.has(key)) { - console.log(`Replaced track#${key}`) - } else console.log(`Added track#${key}`) - - console.log(JSON.stringify(response)) - this.cache.set(key, {response, timeoutId}) - } - - // Clear the cache - clearCache() { - // Clear all timeouts and cache entries - for (const [key, { timeoutId }] of this.cache.entries()) { - clearTimeout(timeoutId) - this.cache.delete(key) - } - } - // Get track with ID async getTrack(trackId) { console.log(`Trying to get track with id ${trackId}`) @@ -100,6 +63,8 @@ class SpotifyProxy { track.genre = genre // append genre to the song this.addToCache(track.id, track) }); + + return response.data.tracks } catch (error) { // TODO: error handling throw error @@ -108,49 +73,6 @@ class SpotifyProxy { // TODO: Form recommendation from cache // TODO: could race condition happens here? } - - // (base) Get a random track with filter - // returns null if no match - getRandomTrackFilter(filter) { - const filteredValues = [...this.cache.values()] - .map(entry => entry.response) - .filter((value) => filter(value)) - if (filteredValues.length == 0) return null - return filteredValues[~~(Math.random() * filteredValues.length)] - } - - // Get a random track - getRandomTrack() { - return this.getRandomTrackFilter(ALL_FILTER) - } - - // Get a random track by genre - async getRandomTrackByGenre(genre) { - console.log("Fetching new recommendations of genre", genre) - await this.recommendTracks(genre) - - console.log("Filtering results") - const filter = PREVIEW_GENRE_FILTER_FAC(genre) - const result = this.getRandomTrackFilter(filter) - - return result - } -} - -// Factory to produce different strategies handling rate limits -const LOW_RATE_CACHE = 600000 // 10 minutes -const MID_RATE_CACHE = LOW_RATE_CACHE * 2 // 20 minutes -const HIGH_RATE_CACHE = MID_RATE_CACHE * 2 // 40 minutes - -class CacheHandler { - constructor() { - - } - - static cacheDuration(rateLimit) { - // TODO: strategies - return LOW_RATE_CACHE - } } async function test() { @@ -160,6 +82,4 @@ async function test() { console.log(proxy.getRandomTrackByGenre("pop")) } -module.exports = { - SpotifyProxy -} \ No newline at end of file +module.exports = SpotifyProxy \ No newline at end of file From efb9cc1c26c24e46980c7e0a0f97ac4ffd61b420 Mon Sep 17 00:00:00 2001 From: Haotian Yi Date: Sat, 30 Nov 2024 10:19:26 -0800 Subject: [PATCH 30/51] Bug fixes related to questionSet generation --- backend/utility/gameManager.js | 28 ++++++++++++++++++++-------- backend/utility/questionSet.js | 2 +- 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/backend/utility/gameManager.js b/backend/utility/gameManager.js index 6c187e1..3c11537 100644 --- a/backend/utility/gameManager.js +++ b/backend/utility/gameManager.js @@ -1,20 +1,24 @@ -import { SpotifyProxy } from "../controller/spotifyProxy"; -import { QuestionSet } from "./questionSet"; +const express = require('express') +require("dotenv").config() + +const DumbProxy = require("../controller/dumbProxy") +// import { SpotifyProxy } from "../controller/spotifyProxy"; +const { QuestionSet } = require("./questionSet") // Spotify proxy -const proxy = SpotifyProxy.getInstance() +const proxy = DumbProxy.getInstance() // Generate a question set -const generateQuestionSet = (genre='pop', choices=4) => { +const generateQuestionSet = async(genre='pop', choices=4) => { let tracks = [] let withPreview = [] while (withPreview.length == 0) { - tracks = proxy.recommendTracks(genre, choices*2) // increase the hit rate of preview_url + tracks = await proxy.recommendTracks(genre, choices*2) // increase the hit rate of preview_url withPreview = tracks.filter((track) => track.preview_url) } - const correctTrack = withPreview + const correctTrack = withPreview[0] const incorrectTracks = tracks .filter((track) => track !== correctTrack) .sort(() => 0.5 - Math.random()) @@ -31,16 +35,24 @@ const generateQuestionSet = (genre='pop', choices=4) => { } // Generate question sets -const generateQuestionSets = (count, genre='pop', choices=4) => { +const generateQuestionSets = async(count, genre='pop', choices=4) => { let qss = [] for (let i = 0; i < count; i++) { - qss.push(generateQuestionSet(genre, choices)) + qss.push(await generateQuestionSet(genre, choices)) } return qss } +const test = async() => { + let proxy = DumbProxy.getInstance() + let qss = await generateQuestionSets(5) + console.log(qss) +} + +// test() + module.exports = { generateQuestionSet, generateQuestionSets diff --git a/backend/utility/questionSet.js b/backend/utility/questionSet.js index f696178..d262602 100644 --- a/backend/utility/questionSet.js +++ b/backend/utility/questionSet.js @@ -2,7 +2,7 @@ class QuestionSet { // Class for multiple-choice with one answer static QID = 0 // unique identifier constructor(tracks, correct) { - this.id = QID++ + this.id = QuestionSet.QID++ this.options = tracks.filter((track) => { return { From 6a9392dc4a93bdc7d2a646942bbaf9c30a8921ff Mon Sep 17 00:00:00 2001 From: Haotian Yi Date: Sat, 30 Nov 2024 10:24:51 -0800 Subject: [PATCH 31/51] Propagation of generated questionSets between front/back ends --- backend/routes/gameRouter.js | 20 ++++++++++++++++++-- frontend/src/Game.js | 17 ++++++++++++----- 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/backend/routes/gameRouter.js b/backend/routes/gameRouter.js index 37fe8cc..e15367e 100644 --- a/backend/routes/gameRouter.js +++ b/backend/routes/gameRouter.js @@ -7,6 +7,7 @@ const User = require("../models/userModel"); const mongoose = require("mongoose"); const { Room } = require("../utility/room"); const { Player } = require("../utility/player"); +const { generateQuestionSets } = require("../utility/gameManager"); function normalizeDate(date) { const normalized = new Date(date); @@ -28,7 +29,11 @@ router.post("/joinRoom", async (req, res) => { room.join(player) // join // Response - res.status(200).json({ code: code, players: Array.from(room.players) }) + res.status(200).json({ + code: code, + players: Array.from(room.players), + questionSets: room.questionSets + }) } else { res.status(400) // bad request, room doesn't exist @@ -46,13 +51,24 @@ router.post("/createRoom", async (req, res) => { try { const { userId } = req.body // Get room code & player infos + // Create room let newRoom = new Room() let player = new Player(userId) newRoom.join(player) console.log("Created room with code", newRoom.getRoomCode()) - res.status(200).json({code: newRoom.getRoomCode()}) + + // Generate question sets + newRoom.addQuestionSets(await generateQuestionSets(5)) // TODO: more options + + console.log("Question sets generated", newRoom.questionSets) + + // Response + res.status(200).json({ + code: newRoom.getRoomCode(), + questionSets: newRoom.questionSets + }) } catch(error) { console.error("Error creating room:", error); res.status(500).json({ error: "Internal server error." }); diff --git a/frontend/src/Game.js b/frontend/src/Game.js index 87342f6..65ed852 100644 --- a/frontend/src/Game.js +++ b/frontend/src/Game.js @@ -68,11 +68,7 @@ function Game() { } setIsPlaying(false); if (answer === correctAnswer) { - // exponential decay calculation for points - const timeElapsed = (Date.now() - startTime) / 1000; - const initialPoints = 100; // max possible points - const decayRate = 0.05; // how quickly points decrease over time - const earnedPoints = Math.round(initialPoints * Math.exp(-decayRate * timeElapsed)); + const earnedPoints = calculateScore() setPoints((prevPoints) => prevPoints + earnedPoints); // NEED TO MODIFY AFTER alert(`Correct! ${earnedPoints} points.`); @@ -83,6 +79,17 @@ function Game() { } }; + // Calculate local score when submitting answer + const calculateScore = () => { + // exponential decay calculation for points + const timeElapsed = (Date.now() - startTime) / 1000; + const initialPoints = 100; // max possible points + const decayRate = 0.05; // how quickly points decrease over time + const earnedPoints = Math.round(initialPoints * Math.exp(-decayRate * timeElapsed)); + + return earnedPoints + } + return (
From aa5ab4ca8989d7ec123dd56343477376c2e6956f Mon Sep 17 00:00:00 2001 From: Haotian Yi Date: Sat, 30 Nov 2024 11:28:39 -0800 Subject: [PATCH 32/51] Added client submission & server verification --- backend/routes/gameRouter.js | 16 ++-- backend/utility/room.js | 2 + frontend/src/Game.js | 94 +++++++++++-------- frontend/src/pages/CreateJoinRoom.js | 2 +- .../src/pages/components/QuestionComponent.js | 73 ++++++++++++++ .../src/pages/styles/QuestionComponent.css | 0 6 files changed, 141 insertions(+), 46 deletions(-) create mode 100644 frontend/src/pages/components/QuestionComponent.js create mode 100644 frontend/src/pages/styles/QuestionComponent.css diff --git a/backend/routes/gameRouter.js b/backend/routes/gameRouter.js index e15367e..bec8a3b 100644 --- a/backend/routes/gameRouter.js +++ b/backend/routes/gameRouter.js @@ -29,10 +29,12 @@ router.post("/joinRoom", async (req, res) => { room.join(player) // join // Response + const updatedQuestionSets = newRoom.questionSets.map(({ correct, ...rest }) => rest); + res.status(200).json({ code: code, players: Array.from(room.players), - questionSets: room.questionSets + questionSets: updatedQuestionSets // hide correct answers }) } else { @@ -65,9 +67,11 @@ router.post("/createRoom", async (req, res) => { console.log("Question sets generated", newRoom.questionSets) // Response + const updatedQuestionSets = newRoom.questionSets.map(({ correct, ...rest }) => rest); + res.status(200).json({ code: newRoom.getRoomCode(), - questionSets: newRoom.questionSets + questionSets: updatedQuestionSets // hide correct answers }) } catch(error) { console.error("Error creating room:", error); @@ -77,19 +81,19 @@ router.post("/createRoom", async (req, res) => { router.post("/submitAnswer", async (req, res) => { try { - const { code, userId, qSet, choice, score } = req.body // Room code | userId | questionSet index | choice index + const { code, userId, idx, choice, score } = req.body // Room code | userId | questionSet index | choice index // Verify answer let room = Room.getRoom(code) - let questionSet = room.questionSets[qSet] // TODO: error handling + let questionSet = room.questionSets[idx] // TODO: error handling let correct = questionSet.isCorrect(choice) if (correct) { // correct answer - let player = room.players[userId] + let player = room.players.get(userId) player.addScore(score) console.log("Room %s, player %s add %d score", code, userId, score) } else { // wrong answer - let player = room.players[userId] + let player = room.players.get(userId) player.update() console.log("Room %s, player %s wrong answer", code, userId) } diff --git a/backend/utility/room.js b/backend/utility/room.js index 2a6810c..b6c79dc 100644 --- a/backend/utility/room.js +++ b/backend/utility/room.js @@ -27,6 +27,8 @@ class Room { this.code = this.generateRoomCode() Room.codePair[this.code] = this // generate room code & add to the dictionary + + console.log(Room.codePair) } // generate room code, which is a number ROOM_CODE_DIGITS long diff --git a/frontend/src/Game.js b/frontend/src/Game.js index 65ed852..00b9465 100644 --- a/frontend/src/Game.js +++ b/frontend/src/Game.js @@ -1,6 +1,7 @@ import React, { useState } from "react"; import "./Game.css"; import { useLocation } from "react-router-dom"; +import QuestionComponent from "./pages/components/QuestionComponent"; const SERVER = process.env.REACT_APP_SERVER; @@ -13,10 +14,14 @@ function Game() { const [startTime, setStartTime] = useState(null); // state to hold time const [points, setPoints] = useState(0); // state to hold points const [round, setRound] = useState(1); + const [idx, setIdx] = useState(0); // state to hold which question we are playing const location = useLocation() const { state } = location || {} + const userData = localStorage.getItem("userData"); + const { userId } = JSON.parse(userData); + // play random previewurl const playRandomPreview = async () => { @@ -79,10 +84,52 @@ function Game() { } }; + // Handle answer submission to the server for verification + const handleAnswerSubmission = async(idx, selectedOption, elapsedTime) => { + const score = calculateScore(elapsedTime) // local score + + try { + const response = await fetch(`${SERVER}/game/submitAnswer`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + code: state.code, // room code + userId: userId, // userId + idx: idx, // qSet index + choice: selectedOption, // index of selected option + score: score // local score + }) + }); + + if (!response.ok) { + throw new Error("Failed to submit answer."); + } + + const data = await response.json(); + console.log("Server feedback:", data); + + // Post-verifcation + if (data.correct === true) { // correct answer + setPoints(points + data.score) + alert("Correct answer!") + } else { + alert("Wrong answer!") + } + + // Increase current index + setIdx(idx + 1) + + } catch (error) { + console.error("Error submitting answers:", error); + } + } + // Calculate local score when submitting answer - const calculateScore = () => { + const calculateScore = (elapsedTime) => { // exponential decay calculation for points - const timeElapsed = (Date.now() - startTime) / 1000; + const timeElapsed = (elapsedTime) / 1000; const initialPoints = 100; // max possible points const decayRate = 0.05; // how quickly points decrease over time const earnedPoints = Math.round(initialPoints * Math.exp(-decayRate * timeElapsed)); @@ -94,44 +141,13 @@ function Game() {

Room {state.code}

- - - - - {isPlaying && answerOptions.length > 0 && ( -
-

Guess the Track:

- {answerOptions.map((option, index) => ( - - ))} -
- )}

Total Points: {points}

+ + {idx < state.questionSets.length ? ( + + ) : ( +

You have completed all the questions! Congratulations!

+ )}
); diff --git a/frontend/src/pages/CreateJoinRoom.js b/frontend/src/pages/CreateJoinRoom.js index 88d8b5c..d5b85e4 100644 --- a/frontend/src/pages/CreateJoinRoom.js +++ b/frontend/src/pages/CreateJoinRoom.js @@ -28,7 +28,7 @@ const CreateJoinRoom = () => { const data = await response.json(); console.log("Room created:", data); - navigate("game", {state: {code: data.code}}) + navigate("game", {state: {code: data.code, questionSets: data.questionSets}}) } catch (error) { console.error("Error creating room:", error); } diff --git a/frontend/src/pages/components/QuestionComponent.js b/frontend/src/pages/components/QuestionComponent.js new file mode 100644 index 0000000..bc991de --- /dev/null +++ b/frontend/src/pages/components/QuestionComponent.js @@ -0,0 +1,73 @@ +import React, { useState, useEffect } from "react"; + +const QuestionComponent = ({ idx, questionSet, onSubmit }) => { + const [selectedOption, setSelectedOption] = useState(null); + const [startTime, setStartTime] = useState(null); + const [elapsedTime, setElapsedTime] = useState(null); + + useEffect(() => { + // Record the start time when the component is mounted + setStartTime(Date.now()); + }, []); + + const handleOptionClick = (index) => { + console.log("index", index) + setSelectedOption(index); + + // Calculate the time taken and store it + const decisionTime = Date.now() - startTime; + setElapsedTime(decisionTime); + }; + + const handleSubmit = async() => { + if (selectedOption === null) { + alert("Please select an option before submitting."); + return; + } + + // Call the parent callback with the selected option and time taken + await onSubmit(idx, selectedOption, elapsedTime) + }; + + return ( +
+

Question {idx}

+

Choose the correct track based on the preview:

+
    + {questionSet.options.map((option, index) => ( +
  • + +
  • + ))} +
+ +
+ ); +}; + +export default QuestionComponent; diff --git a/frontend/src/pages/styles/QuestionComponent.css b/frontend/src/pages/styles/QuestionComponent.css new file mode 100644 index 0000000..e69de29 From 7f163f1160038fed40c55c119d18ba8f5d2c79e8 Mon Sep 17 00:00:00 2001 From: Haotian Yi Date: Fri, 29 Nov 2024 17:41:33 -0800 Subject: [PATCH 33/51] Room class for in-server storage --- backend/utility/room.js | 115 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 backend/utility/room.js diff --git a/backend/utility/room.js b/backend/utility/room.js new file mode 100644 index 0000000..f9c0747 --- /dev/null +++ b/backend/utility/room.js @@ -0,0 +1,115 @@ +const GAME_STATES = { + OPEN: "open", + PENDING: "pending", + STARTED: "started", + CLOSED: "closed" +} + +const ROOM_CODE_DIGITS = 4 // room code is 4-digits long + +// Room where games are held +// TODO: for now we store rooms in memory, and the total numbers +// of active rooms in any given moment is assumed to be < 10000 +class Room { + static gameId = 0 + + static codePair = {} + + constructor() { + this.id = ++Room.gameId // id + this.state = GAME_STATES.OPEN // game is by default open + this.players = new Set() // a set of players + + this.code = this.generateRoomCode() + Room.codePair[this.code] = this // generate room code & add to the dictionary + } + + // generate room code, which is a number ROOM_CODE_DIGITS long + generateRoomCode() { + var code = this.id % (10 ** ROOM_CODE_DIGITS) + while (code in Room.codePair) code = (code + 1) % (10 ** ROOM_CODE_DIGITS) + return String(code).padStart(ROOM_CODE_DIGITS, '0') + } + getRoomCode() { + return String(this.id).padStart(4, '0') + } + + // get a room + static getRoom(code) { + return Room.codePair[code] + } + + /* Functionalities */ + join(player) { + this.#addPlayer(player) + } + + exit(player) { + this.#removePlayer(player) + } + + saveGameRecords() { + // TODO: database-related method + return null + } + + // players + #addPlayer(player) { + this.players.add(player) + } + + #removePlayer(player) { + this.players.delete(player) + } + + /* Switch States */ + open() { + this.state = GAME_STATES.OPEN + } + + start() { + this.state = GAME_STATES.STARTED + } + + stop() { + this.state = GAME_STATES.PENDING + } + + close() { + this.state = GAME_STATES.CLOSED + delete Room.codePair[this.code] // reuse room ID + this.saveGameRecords() + + // TODO: it might be beneficial to clean room ID & save records separately, in bulk + } + + /* Clean-Up */ + // TODO: cleanup methods + + /* Getters */ + get playerCount() { + return this.players.size + } +} + +function test() { + let room1 = new Room() + let room2 = new Room() + let room3 = new Room() + let room4 = new Room() + + console.log(Room.codePair) + room3.close() + + let room5 = new Room() + + for (let i = 0; i < 996; i++) { + new Room() + } + + console.log(Room.codePair) +} + +module.exports = { + Room +} \ No newline at end of file From e63833491a504eb480d2f1f8e6a2ffcd308c2b47 Mon Sep 17 00:00:00 2001 From: Haotian Yi Date: Fri, 29 Nov 2024 17:53:22 -0800 Subject: [PATCH 34/51] Basic functionality in creating rooms --- backend/routes/gameRouter.js | 24 +++++++++++++++++- frontend/src/PageRouter.js | 6 ++++- frontend/src/pages/CreateJoinRoom.js | 38 ++++++++++++++++++++++++++++ frontend/src/pages/FrontPage.js | 6 ++--- 4 files changed, 69 insertions(+), 5 deletions(-) create mode 100644 frontend/src/pages/CreateJoinRoom.js diff --git a/backend/routes/gameRouter.js b/backend/routes/gameRouter.js index cd0ae6f..95bc0aa 100644 --- a/backend/routes/gameRouter.js +++ b/backend/routes/gameRouter.js @@ -5,6 +5,7 @@ const { getAccessToken } = require("../utility/tokenManager"); const DailyChallenge = require("../models/dailyChallenge"); const User = require("../models/userModel"); const mongoose = require("mongoose"); +const { Room } = require("../utility/room"); function normalizeDate(date) { const normalized = new Date(date); @@ -12,6 +13,27 @@ function normalizeDate(date) { return normalized; } +router.get("/room", async (req, res) => { + try { + + + + } catch (error) { + console.log("Error fetch room status:", error); + res.status(500).json({ error: "Internal server error." }); + } +}) + +router.post("/createRoom", async (req, res) => { + try { + let newRoom = new Room() + console.log("Created room with code", newRoom.getRoomCode()) + } catch(error) { + console.error("Error creating room:", error); + res.status(500).json({ error: "Internal server error." }); + } +}) + router.get("/dailyChallenge", async (req, res) => { try { @@ -42,7 +64,7 @@ router.post("/updateDailyScore", async(req, res) => { // if (user.dailyScore !== -1) { // return res.status(400).json({ error: "Daily challenge already completed." }); - // } + // } user.dailyScore = score; await user.save(); diff --git a/frontend/src/PageRouter.js b/frontend/src/PageRouter.js index d67a7c5..c0e6818 100644 --- a/frontend/src/PageRouter.js +++ b/frontend/src/PageRouter.js @@ -7,6 +7,8 @@ import Game from './Game.js'; import DailyChallengePage from './pages/DailyChallenge.js'; import DailyChallengeLeaderboard from './pages/DailyChallengeLeaderboard.js'; import MatchHistory from './pages/MatchHistory.js'; +import CreateJoinRoom from './pages/CreateJoinRoom.js'; +import JoinRoom from './pages/JoinRoom.js'; const PageRouter = () => { @@ -14,7 +16,9 @@ const PageRouter = () => { } /> - } /> + } /> + } /> + } /> } /> } /> } /> diff --git a/frontend/src/pages/CreateJoinRoom.js b/frontend/src/pages/CreateJoinRoom.js new file mode 100644 index 0000000..4f5b80e --- /dev/null +++ b/frontend/src/pages/CreateJoinRoom.js @@ -0,0 +1,38 @@ +import React, { useState, useEffect } from "react"; +import { Link } from "react-router-dom"; + +const SERVER = process.env.REACT_APP_SERVER; + +const CreateJoinRoom = () => { + const createRoom = async (e) => { + try { + const response = await fetch(`${SERVER}/game/createRoom`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + }); + + if (!response.ok) { + throw new Error("Failed to create new rooms."); + } + + const data = await response.json(); + console.log("Room created:", data); + } catch (error) { + console.error("Error creating room:", error); + } + }; + + return ( +
+

MuseGuesser

+
+ Create Room + Join Room +
+
+ ); +} + +export default CreateJoinRoom; diff --git a/frontend/src/pages/FrontPage.js b/frontend/src/pages/FrontPage.js index dbbc12e..0e68fea 100644 --- a/frontend/src/pages/FrontPage.js +++ b/frontend/src/pages/FrontPage.js @@ -28,12 +28,12 @@ const FrontPage = () => {

MuseGuesser

{!doneDaily && loggedIn ? Daily Challenge : null} - {!loggedIn ?<> + {!loggedIn ?<> Sign In Sign Up - : + : <> - Play Game + Play Game Match History }
From 6dafab8e5cfb60cf1669b11506d4c16bc34f40c4 Mon Sep 17 00:00:00 2001 From: Haotian Yi Date: Fri, 29 Nov 2024 17:58:50 -0800 Subject: [PATCH 35/51] Quicker room code access --- backend/utility/room.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/utility/room.js b/backend/utility/room.js index f9c0747..d689ea8 100644 --- a/backend/utility/room.js +++ b/backend/utility/room.js @@ -31,7 +31,7 @@ class Room { return String(code).padStart(ROOM_CODE_DIGITS, '0') } getRoomCode() { - return String(this.id).padStart(4, '0') + return this.code } // get a room From 6193869d9a2b04f5820fe508c5219ce4d4270772 Mon Sep 17 00:00:00 2001 From: Haotian Yi Date: Fri, 29 Nov 2024 18:47:50 -0800 Subject: [PATCH 36/51] Pass room code to frontend --- backend/routes/gameRouter.js | 1 + frontend/src/Game.js | 7 ++++++- frontend/src/pages/CreateJoinRoom.js | 8 ++++++-- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/backend/routes/gameRouter.js b/backend/routes/gameRouter.js index 95bc0aa..f39e528 100644 --- a/backend/routes/gameRouter.js +++ b/backend/routes/gameRouter.js @@ -28,6 +28,7 @@ router.post("/createRoom", async (req, res) => { try { let newRoom = new Room() console.log("Created room with code", newRoom.getRoomCode()) + res.status(200).json({code: newRoom.getRoomCode()}) } catch(error) { console.error("Error creating room:", error); res.status(500).json({ error: "Internal server error." }); diff --git a/frontend/src/Game.js b/frontend/src/Game.js index 650bce8..87342f6 100644 --- a/frontend/src/Game.js +++ b/frontend/src/Game.js @@ -1,5 +1,6 @@ import React, { useState } from "react"; import "./Game.css"; +import { useLocation } from "react-router-dom"; const SERVER = process.env.REACT_APP_SERVER; @@ -13,9 +14,12 @@ function Game() { const [points, setPoints] = useState(0); // state to hold points const [round, setRound] = useState(1); + const location = useLocation() + const { state } = location || {} + // play random previewurl const playRandomPreview = async () => { - + try { const response = await fetch( @@ -82,6 +86,7 @@ function Game() { return (
+

Room {state.code}

+ +
+ ); +}; + +const styles = { + container: { + display: "flex", + flexDirection: "column", + alignItems: "center", + justifyContent: "center", + height: "100vh", + backgroundColor: "#f5f5f5", + }, + inputContainer: { + margin: "10px 0", + textAlign: "center", + }, + input: { + width: "200px", + padding: "10px", + fontSize: "16px", + borderRadius: "5px", + border: "1px solid #ccc", + textAlign: "center", + marginTop: "5px", + }, + button: { + marginTop: "20px", + padding: "10px 20px", + fontSize: "16px", + borderRadius: "5px", + border: "none", + backgroundColor: "#4caf50", + color: "#fff", + cursor: "pointer", + transition: "background-color 0.3s ease", + }, +}; + +export default JoinRoom; From 70541e0f8e69888b34b98f36ed9a1c00172d1e22 Mon Sep 17 00:00:00 2001 From: Haotian Yi Date: Fri, 29 Nov 2024 19:51:55 -0800 Subject: [PATCH 38/51] Users correctly shown on each create/join --- backend/routes/gameRouter.js | 6 +++++- backend/utility/room.js | 5 +---- frontend/src/pages/CreateJoinRoom.js | 6 ++++++ frontend/src/pages/JoinRoom.js | 2 +- 4 files changed, 13 insertions(+), 6 deletions(-) diff --git a/backend/routes/gameRouter.js b/backend/routes/gameRouter.js index a27b60c..4615b91 100644 --- a/backend/routes/gameRouter.js +++ b/backend/routes/gameRouter.js @@ -25,7 +25,7 @@ router.post("/joinRoom", async (req, res) => { room.join(userId) // join // Response - res.status(200).json({ code: code, players: room.players }) + res.status(200).json({ code: code, players: Array.from(room.players) }) } else { res.status(400) // bad request, room doesn't exist @@ -41,7 +41,11 @@ router.post("/joinRoom", async (req, res) => { router.post("/createRoom", async (req, res) => { try { + const { userId } = req.body // Get room code & player infos + let newRoom = new Room() + newRoom.join(userId) + console.log("Created room with code", newRoom.getRoomCode()) res.status(200).json({code: newRoom.getRoomCode()}) } catch(error) { diff --git a/backend/utility/room.js b/backend/utility/room.js index d689ea8..d173042 100644 --- a/backend/utility/room.js +++ b/backend/utility/room.js @@ -41,6 +41,7 @@ class Room { /* Functionalities */ join(player) { + console.log("Player %s join room %d", player, this.id) this.#addPlayer(player) } @@ -103,10 +104,6 @@ function test() { let room5 = new Room() - for (let i = 0; i < 996; i++) { - new Room() - } - console.log(Room.codePair) } diff --git a/frontend/src/pages/CreateJoinRoom.js b/frontend/src/pages/CreateJoinRoom.js index 098ddfd..88d8b5c 100644 --- a/frontend/src/pages/CreateJoinRoom.js +++ b/frontend/src/pages/CreateJoinRoom.js @@ -4,6 +4,9 @@ import { Link, useNavigate } from "react-router-dom"; const SERVER = process.env.REACT_APP_SERVER; const CreateJoinRoom = () => { + const userData = localStorage.getItem("userData"); + const { userId } = JSON.parse(userData); + const navigate = useNavigate() const createRoom = async (e) => { @@ -13,6 +16,9 @@ const CreateJoinRoom = () => { headers: { "Content-Type": "application/json", }, + body: JSON.stringify({ + userId: userId + }) }); if (!response.ok) { diff --git a/frontend/src/pages/JoinRoom.js b/frontend/src/pages/JoinRoom.js index 67c7be7..4c1ff24 100644 --- a/frontend/src/pages/JoinRoom.js +++ b/frontend/src/pages/JoinRoom.js @@ -30,7 +30,7 @@ const JoinRoom = () => { } const data = await response.json(); - console.log("Joined room:", data.code); + console.log("Joined room:", data); navigate("/room/game", {state: {code: data.code}}) } catch (error) { From 71c379c9c1edde615a5cd3f3894121f55441c825 Mon Sep 17 00:00:00 2001 From: Haotian Yi Date: Fri, 29 Nov 2024 20:24:31 -0800 Subject: [PATCH 39/51] Use Player class to record room game player info --- backend/routes/gameRouter.js | 9 +++++++-- backend/utility/player.js | 28 ++++++++++++++++++++++++++++ backend/utility/room.js | 2 +- 3 files changed, 36 insertions(+), 3 deletions(-) create mode 100644 backend/utility/player.js diff --git a/backend/routes/gameRouter.js b/backend/routes/gameRouter.js index 4615b91..bb67e43 100644 --- a/backend/routes/gameRouter.js +++ b/backend/routes/gameRouter.js @@ -6,6 +6,7 @@ const DailyChallenge = require("../models/dailyChallenge"); const User = require("../models/userModel"); const mongoose = require("mongoose"); const { Room } = require("../utility/room"); +const { Player } = require("../utility/player"); function normalizeDate(date) { const normalized = new Date(date); @@ -22,7 +23,9 @@ router.post("/joinRoom", async (req, res) => { let room = Room.getRoom(code) if (room !== undefined) { // room exists // TODO: Room operation - room.join(userId) // join + let player = new Player(userId) + + room.join(player) // join // Response res.status(200).json({ code: code, players: Array.from(room.players) }) @@ -44,7 +47,9 @@ router.post("/createRoom", async (req, res) => { const { userId } = req.body // Get room code & player infos let newRoom = new Room() - newRoom.join(userId) + + let player = new Player(userId) + newRoom.join(player) console.log("Created room with code", newRoom.getRoomCode()) res.status(200).json({code: newRoom.getRoomCode()}) diff --git a/backend/utility/player.js b/backend/utility/player.js new file mode 100644 index 0000000..a3d50e3 --- /dev/null +++ b/backend/utility/player.js @@ -0,0 +1,28 @@ +// Player model used in Rooms +class Player { // player is specific to room + constructor(userId) { + this.id = userId // unique identifier + this.score = 0 // starts with 0 score + this.lastUpdate = Date.now() // last update + } + + // Is the player expired? + isExpired(expiration = 1000 * 60 * 5) { // default to be 5 minutes + return Date.now() > this.lastUpdate + expiration + } + + // Add score + addScore(delta) { + this.score += delta + this.update() + } + + // Pulse - update lastUpdate + update() { + this.lastUpdate = Date.now() + } +} + +module.exports = { + Player +} \ No newline at end of file diff --git a/backend/utility/room.js b/backend/utility/room.js index d173042..c781e37 100644 --- a/backend/utility/room.js +++ b/backend/utility/room.js @@ -55,7 +55,7 @@ class Room { } // players - #addPlayer(player) { + #addPlayer(player) { // Player this.players.add(player) } From d88269398cb22756d9f9694ce3d3a26d508e9fbb Mon Sep 17 00:00:00 2001 From: Haotian Yi Date: Fri, 29 Nov 2024 21:06:35 -0800 Subject: [PATCH 40/51] Questionset generation --- backend/utility/gameManager.js | 47 ++++++++++++++++++++++++++++++++++ backend/utility/questionSet.js | 26 +++++++++++++++++++ 2 files changed, 73 insertions(+) create mode 100644 backend/utility/gameManager.js create mode 100644 backend/utility/questionSet.js diff --git a/backend/utility/gameManager.js b/backend/utility/gameManager.js new file mode 100644 index 0000000..6c187e1 --- /dev/null +++ b/backend/utility/gameManager.js @@ -0,0 +1,47 @@ +import { SpotifyProxy } from "../controller/spotifyProxy"; +import { QuestionSet } from "./questionSet"; + +// Spotify proxy +const proxy = SpotifyProxy.getInstance() + +// Generate a question set +const generateQuestionSet = (genre='pop', choices=4) => { + let tracks = [] + let withPreview = [] + + while (withPreview.length == 0) { + tracks = proxy.recommendTracks(genre, choices*2) // increase the hit rate of preview_url + withPreview = tracks.filter((track) => track.preview_url) + } + + const correctTrack = withPreview + const incorrectTracks = tracks + .filter((track) => track !== correctTrack) + .sort(() => 0.5 - Math.random()) + .slice(0, choices - 1); + const correct = Math.floor(Math.random() * choices) + const finalTracks = [ + ...incorrectTracks.slice(0, correct), + correctTrack, + ...incorrectTracks.slice(correct) + ] + + let qs = new QuestionSet(finalTracks, correct) // generate question sets + return qs +} + +// Generate question sets +const generateQuestionSets = (count, genre='pop', choices=4) => { + let qss = [] + + for (let i = 0; i < count; i++) { + qss.push(generateQuestionSet(genre, choices)) + } + + return qss +} + +module.exports = { + generateQuestionSet, + generateQuestionSets +} \ No newline at end of file diff --git a/backend/utility/questionSet.js b/backend/utility/questionSet.js new file mode 100644 index 0000000..f696178 --- /dev/null +++ b/backend/utility/questionSet.js @@ -0,0 +1,26 @@ +class QuestionSet { // Class for multiple-choice with one answer + static QID = 0 // unique identifier + + constructor(tracks, correct) { + this.id = QID++ + + this.options = tracks.filter((track) => { + return { + name: track.name, + artist: track.artists[0].name + } + }) + + this.correct = correct + this.url = tracks[correct].preview_url // url + } + + // If choice is correct + isCorrect(choice) { + return choice == this.correct + } +} + +module.exports = { + QuestionSet +} \ No newline at end of file From 3bad42f078768f75a1d028335bd608fe58c48882 Mon Sep 17 00:00:00 2001 From: Haotian Yi Date: Fri, 29 Nov 2024 21:25:50 -0800 Subject: [PATCH 41/51] Room model & saveGameRecords --- backend/models/roomModel.js | 22 ++++++++++++++++++++++ backend/utility/room.js | 27 ++++++++++++++++++++++----- 2 files changed, 44 insertions(+), 5 deletions(-) create mode 100644 backend/models/roomModel.js diff --git a/backend/models/roomModel.js b/backend/models/roomModel.js new file mode 100644 index 0000000..8cd6b0a --- /dev/null +++ b/backend/models/roomModel.js @@ -0,0 +1,22 @@ +const mongoose = require("mongoose"); + +const roomSchema = new mongoose.Schema({ + players: { + type: Array, + default: [] // { userId, score } + } +}); + +// Save game records +roomSchema.methods.saveGameRecords = async function (players) { + const playerRecords = players.map((player) => ({ + userId: player.id, + score: player.score + })); + + this.players = playerRecords; + + return await this.save(); +} + +module.exports = mongoose.model("RoomModel", roomSchema) \ No newline at end of file diff --git a/backend/utility/room.js b/backend/utility/room.js index c781e37..864a40c 100644 --- a/backend/utility/room.js +++ b/backend/utility/room.js @@ -1,3 +1,5 @@ +const RoomModel = require('../models/roomModel') + const GAME_STATES = { OPEN: "open", PENDING: "pending", @@ -18,10 +20,13 @@ class Room { constructor() { this.id = ++Room.gameId // id this.state = GAME_STATES.OPEN // game is by default open - this.players = new Set() // a set of players + this.players = new Set() // a set of players + this.questionSets = [] // questionSets + + this.model = new RoomModel() // database model this.code = this.generateRoomCode() - Room.codePair[this.code] = this // generate room code & add to the dictionary + Room.codePair[this.code] = this // generate room code & add to the dictionary } // generate room code, which is a number ROOM_CODE_DIGITS long @@ -49,9 +54,12 @@ class Room { this.#removePlayer(player) } - saveGameRecords() { - // TODO: database-related method - return null + // Save this room (results) to database + async saveGameRecords() { + const room = await RoomModel.findById(this.id) || new RoomModel() + await room.saveGameRecords(this.players) // save player records + + console.log("Room %s records saved.", this.getRoomCode()) } // players @@ -84,6 +92,15 @@ class Room { // TODO: it might be beneficial to clean room ID & save records separately, in bulk } + /* Question Sets */ + addQuesitonSet(questionSet) { + this.questionSets.push(questionSet) + } + + addQuestionSets(questionSets) { + this.questionSets.push(...questionSets) + } + /* Clean-Up */ // TODO: cleanup methods From 877681f17d3e28a4d4575c8202ee2c9c21335774 Mon Sep 17 00:00:00 2001 From: Haotian Yi Date: Fri, 29 Nov 2024 21:42:41 -0800 Subject: [PATCH 42/51] Server-side verification --- backend/routes/gameRouter.js | 30 ++++++++++++++++++++++++++++++ backend/utility/room.js | 8 ++++---- 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/backend/routes/gameRouter.js b/backend/routes/gameRouter.js index bb67e43..37fe8cc 100644 --- a/backend/routes/gameRouter.js +++ b/backend/routes/gameRouter.js @@ -59,6 +59,36 @@ router.post("/createRoom", async (req, res) => { } }) +router.post("/submitAnswer", async (req, res) => { + try { + const { code, userId, qSet, choice, score } = req.body // Room code | userId | questionSet index | choice index + + // Verify answer + let room = Room.getRoom(code) + let questionSet = room.questionSets[qSet] // TODO: error handling + + let correct = questionSet.isCorrect(choice) + if (correct) { // correct answer + let player = room.players[userId] + player.addScore(score) + console.log("Room %s, player %s add %d score", code, userId, score) + } else { // wrong answer + let player = room.players[userId] + player.update() + console.log("Room %s, player %s wrong answer", code, userId) + } + + res.status(200).json({ + correct: correct, + score: score + }) + + } catch(error) { + console.error("Error verifying answer:", error); + res.status(500).json({ error: "Internal server error." }); + } +}) + router.get("/dailyChallenge", async (req, res) => { try { diff --git a/backend/utility/room.js b/backend/utility/room.js index 864a40c..ca411c2 100644 --- a/backend/utility/room.js +++ b/backend/utility/room.js @@ -20,7 +20,7 @@ class Room { constructor() { this.id = ++Room.gameId // id this.state = GAME_STATES.OPEN // game is by default open - this.players = new Set() // a set of players + this.players = new Map() // a map of players this.questionSets = [] // questionSets this.model = new RoomModel() // database model @@ -57,18 +57,18 @@ class Room { // Save this room (results) to database async saveGameRecords() { const room = await RoomModel.findById(this.id) || new RoomModel() - await room.saveGameRecords(this.players) // save player records + await room.saveGameRecords(this.players.values()) // save player records console.log("Room %s records saved.", this.getRoomCode()) } // players #addPlayer(player) { // Player - this.players.add(player) + this.players.set(player.id, player) } #removePlayer(player) { - this.players.delete(player) + this.players.delete(player.id) } /* Switch States */ From 48f6637394cded6a8465a22af65ab28950481474 Mon Sep 17 00:00:00 2001 From: Haotian Yi Date: Fri, 29 Nov 2024 21:49:43 -0800 Subject: [PATCH 43/51] Fixes a saveGameRecords bug --- backend/utility/room.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/backend/utility/room.js b/backend/utility/room.js index ca411c2..bbb430a 100644 --- a/backend/utility/room.js +++ b/backend/utility/room.js @@ -56,7 +56,7 @@ class Room { // Save this room (results) to database async saveGameRecords() { - const room = await RoomModel.findById(this.id) || new RoomModel() + const room = await RoomModel.findById(this.model._id) || new RoomModel() await room.saveGameRecords(this.players.values()) // save player records console.log("Room %s records saved.", this.getRoomCode()) @@ -119,11 +119,15 @@ function test() { console.log(Room.codePair) room3.close() + room4.saveGameRecords() + let room5 = new Room() console.log(Room.codePair) } +test() + module.exports = { Room } \ No newline at end of file From e18dc36ae6f0df056dfecf36a3939e427a34a57f Mon Sep 17 00:00:00 2001 From: Haotian Yi Date: Sat, 30 Nov 2024 09:28:28 -0800 Subject: [PATCH 44/51] Bug fixes related to saveGameRecords --- backend/db.js | 3 ++- backend/utility/room.js | 8 +++----- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/backend/db.js b/backend/db.js index da81fc7..bbbabd5 100644 --- a/backend/db.js +++ b/backend/db.js @@ -18,5 +18,6 @@ const Song = require('./models/songModel.js'); const User = require('./models/userModel.js'); const DailyChallenge = require('./models/dailyChallenge.js') const Match = require('./models/matchModel.js'); +const RoomModel = require('./models/roomModel.js') -module.exports = {User, Song, DailyChallenge, Match}; \ No newline at end of file +module.exports = {User, Song, DailyChallenge, Match, RoomModel}; diff --git a/backend/utility/room.js b/backend/utility/room.js index bbb430a..2a6810c 100644 --- a/backend/utility/room.js +++ b/backend/utility/room.js @@ -1,4 +1,4 @@ -const RoomModel = require('../models/roomModel') +const { RoomModel } = require("../db") const GAME_STATES = { OPEN: "open", @@ -56,9 +56,7 @@ class Room { // Save this room (results) to database async saveGameRecords() { - const room = await RoomModel.findById(this.model._id) || new RoomModel() - await room.saveGameRecords(this.players.values()) // save player records - + await this.model.saveGameRecords(Array.from(this.players.values())) // save player records console.log("Room %s records saved.", this.getRoomCode()) } @@ -126,7 +124,7 @@ function test() { console.log(Room.codePair) } -test() +// test() module.exports = { Room From 33761a83417f5f7f9e5dd63cc62bebe58584dce2 Mon Sep 17 00:00:00 2001 From: Haotian Yi Date: Sat, 30 Nov 2024 10:19:26 -0800 Subject: [PATCH 45/51] Bug fixes related to questionSet generation --- backend/utility/gameManager.js | 28 ++++++++++++++++++++-------- backend/utility/questionSet.js | 2 +- 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/backend/utility/gameManager.js b/backend/utility/gameManager.js index 6c187e1..3c11537 100644 --- a/backend/utility/gameManager.js +++ b/backend/utility/gameManager.js @@ -1,20 +1,24 @@ -import { SpotifyProxy } from "../controller/spotifyProxy"; -import { QuestionSet } from "./questionSet"; +const express = require('express') +require("dotenv").config() + +const DumbProxy = require("../controller/dumbProxy") +// import { SpotifyProxy } from "../controller/spotifyProxy"; +const { QuestionSet } = require("./questionSet") // Spotify proxy -const proxy = SpotifyProxy.getInstance() +const proxy = DumbProxy.getInstance() // Generate a question set -const generateQuestionSet = (genre='pop', choices=4) => { +const generateQuestionSet = async(genre='pop', choices=4) => { let tracks = [] let withPreview = [] while (withPreview.length == 0) { - tracks = proxy.recommendTracks(genre, choices*2) // increase the hit rate of preview_url + tracks = await proxy.recommendTracks(genre, choices*2) // increase the hit rate of preview_url withPreview = tracks.filter((track) => track.preview_url) } - const correctTrack = withPreview + const correctTrack = withPreview[0] const incorrectTracks = tracks .filter((track) => track !== correctTrack) .sort(() => 0.5 - Math.random()) @@ -31,16 +35,24 @@ const generateQuestionSet = (genre='pop', choices=4) => { } // Generate question sets -const generateQuestionSets = (count, genre='pop', choices=4) => { +const generateQuestionSets = async(count, genre='pop', choices=4) => { let qss = [] for (let i = 0; i < count; i++) { - qss.push(generateQuestionSet(genre, choices)) + qss.push(await generateQuestionSet(genre, choices)) } return qss } +const test = async() => { + let proxy = DumbProxy.getInstance() + let qss = await generateQuestionSets(5) + console.log(qss) +} + +// test() + module.exports = { generateQuestionSet, generateQuestionSets diff --git a/backend/utility/questionSet.js b/backend/utility/questionSet.js index f696178..d262602 100644 --- a/backend/utility/questionSet.js +++ b/backend/utility/questionSet.js @@ -2,7 +2,7 @@ class QuestionSet { // Class for multiple-choice with one answer static QID = 0 // unique identifier constructor(tracks, correct) { - this.id = QID++ + this.id = QuestionSet.QID++ this.options = tracks.filter((track) => { return { From e9e67011ec498ab496e0d8b713b9f371c5ee9f59 Mon Sep 17 00:00:00 2001 From: Haotian Yi Date: Sat, 30 Nov 2024 10:24:51 -0800 Subject: [PATCH 46/51] Propagation of generated questionSets between front/back ends --- backend/routes/gameRouter.js | 20 ++++++++++++++++++-- frontend/src/Game.js | 17 ++++++++++++----- 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/backend/routes/gameRouter.js b/backend/routes/gameRouter.js index 37fe8cc..e15367e 100644 --- a/backend/routes/gameRouter.js +++ b/backend/routes/gameRouter.js @@ -7,6 +7,7 @@ const User = require("../models/userModel"); const mongoose = require("mongoose"); const { Room } = require("../utility/room"); const { Player } = require("../utility/player"); +const { generateQuestionSets } = require("../utility/gameManager"); function normalizeDate(date) { const normalized = new Date(date); @@ -28,7 +29,11 @@ router.post("/joinRoom", async (req, res) => { room.join(player) // join // Response - res.status(200).json({ code: code, players: Array.from(room.players) }) + res.status(200).json({ + code: code, + players: Array.from(room.players), + questionSets: room.questionSets + }) } else { res.status(400) // bad request, room doesn't exist @@ -46,13 +51,24 @@ router.post("/createRoom", async (req, res) => { try { const { userId } = req.body // Get room code & player infos + // Create room let newRoom = new Room() let player = new Player(userId) newRoom.join(player) console.log("Created room with code", newRoom.getRoomCode()) - res.status(200).json({code: newRoom.getRoomCode()}) + + // Generate question sets + newRoom.addQuestionSets(await generateQuestionSets(5)) // TODO: more options + + console.log("Question sets generated", newRoom.questionSets) + + // Response + res.status(200).json({ + code: newRoom.getRoomCode(), + questionSets: newRoom.questionSets + }) } catch(error) { console.error("Error creating room:", error); res.status(500).json({ error: "Internal server error." }); diff --git a/frontend/src/Game.js b/frontend/src/Game.js index 87342f6..65ed852 100644 --- a/frontend/src/Game.js +++ b/frontend/src/Game.js @@ -68,11 +68,7 @@ function Game() { } setIsPlaying(false); if (answer === correctAnswer) { - // exponential decay calculation for points - const timeElapsed = (Date.now() - startTime) / 1000; - const initialPoints = 100; // max possible points - const decayRate = 0.05; // how quickly points decrease over time - const earnedPoints = Math.round(initialPoints * Math.exp(-decayRate * timeElapsed)); + const earnedPoints = calculateScore() setPoints((prevPoints) => prevPoints + earnedPoints); // NEED TO MODIFY AFTER alert(`Correct! ${earnedPoints} points.`); @@ -83,6 +79,17 @@ function Game() { } }; + // Calculate local score when submitting answer + const calculateScore = () => { + // exponential decay calculation for points + const timeElapsed = (Date.now() - startTime) / 1000; + const initialPoints = 100; // max possible points + const decayRate = 0.05; // how quickly points decrease over time + const earnedPoints = Math.round(initialPoints * Math.exp(-decayRate * timeElapsed)); + + return earnedPoints + } + return (
From 7cbc36dd85555338c5be6c71ca5cb25f0ea14c4a Mon Sep 17 00:00:00 2001 From: Haotian Yi Date: Sat, 30 Nov 2024 11:28:39 -0800 Subject: [PATCH 47/51] Added client submission & server verification --- backend/routes/gameRouter.js | 16 ++-- backend/utility/room.js | 2 + frontend/src/Game.js | 94 +++++++++++-------- frontend/src/pages/CreateJoinRoom.js | 2 +- .../src/pages/components/QuestionComponent.js | 73 ++++++++++++++ .../src/pages/styles/QuestionComponent.css | 0 6 files changed, 141 insertions(+), 46 deletions(-) create mode 100644 frontend/src/pages/components/QuestionComponent.js create mode 100644 frontend/src/pages/styles/QuestionComponent.css diff --git a/backend/routes/gameRouter.js b/backend/routes/gameRouter.js index e15367e..bec8a3b 100644 --- a/backend/routes/gameRouter.js +++ b/backend/routes/gameRouter.js @@ -29,10 +29,12 @@ router.post("/joinRoom", async (req, res) => { room.join(player) // join // Response + const updatedQuestionSets = newRoom.questionSets.map(({ correct, ...rest }) => rest); + res.status(200).json({ code: code, players: Array.from(room.players), - questionSets: room.questionSets + questionSets: updatedQuestionSets // hide correct answers }) } else { @@ -65,9 +67,11 @@ router.post("/createRoom", async (req, res) => { console.log("Question sets generated", newRoom.questionSets) // Response + const updatedQuestionSets = newRoom.questionSets.map(({ correct, ...rest }) => rest); + res.status(200).json({ code: newRoom.getRoomCode(), - questionSets: newRoom.questionSets + questionSets: updatedQuestionSets // hide correct answers }) } catch(error) { console.error("Error creating room:", error); @@ -77,19 +81,19 @@ router.post("/createRoom", async (req, res) => { router.post("/submitAnswer", async (req, res) => { try { - const { code, userId, qSet, choice, score } = req.body // Room code | userId | questionSet index | choice index + const { code, userId, idx, choice, score } = req.body // Room code | userId | questionSet index | choice index // Verify answer let room = Room.getRoom(code) - let questionSet = room.questionSets[qSet] // TODO: error handling + let questionSet = room.questionSets[idx] // TODO: error handling let correct = questionSet.isCorrect(choice) if (correct) { // correct answer - let player = room.players[userId] + let player = room.players.get(userId) player.addScore(score) console.log("Room %s, player %s add %d score", code, userId, score) } else { // wrong answer - let player = room.players[userId] + let player = room.players.get(userId) player.update() console.log("Room %s, player %s wrong answer", code, userId) } diff --git a/backend/utility/room.js b/backend/utility/room.js index 2a6810c..b6c79dc 100644 --- a/backend/utility/room.js +++ b/backend/utility/room.js @@ -27,6 +27,8 @@ class Room { this.code = this.generateRoomCode() Room.codePair[this.code] = this // generate room code & add to the dictionary + + console.log(Room.codePair) } // generate room code, which is a number ROOM_CODE_DIGITS long diff --git a/frontend/src/Game.js b/frontend/src/Game.js index 65ed852..00b9465 100644 --- a/frontend/src/Game.js +++ b/frontend/src/Game.js @@ -1,6 +1,7 @@ import React, { useState } from "react"; import "./Game.css"; import { useLocation } from "react-router-dom"; +import QuestionComponent from "./pages/components/QuestionComponent"; const SERVER = process.env.REACT_APP_SERVER; @@ -13,10 +14,14 @@ function Game() { const [startTime, setStartTime] = useState(null); // state to hold time const [points, setPoints] = useState(0); // state to hold points const [round, setRound] = useState(1); + const [idx, setIdx] = useState(0); // state to hold which question we are playing const location = useLocation() const { state } = location || {} + const userData = localStorage.getItem("userData"); + const { userId } = JSON.parse(userData); + // play random previewurl const playRandomPreview = async () => { @@ -79,10 +84,52 @@ function Game() { } }; + // Handle answer submission to the server for verification + const handleAnswerSubmission = async(idx, selectedOption, elapsedTime) => { + const score = calculateScore(elapsedTime) // local score + + try { + const response = await fetch(`${SERVER}/game/submitAnswer`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + code: state.code, // room code + userId: userId, // userId + idx: idx, // qSet index + choice: selectedOption, // index of selected option + score: score // local score + }) + }); + + if (!response.ok) { + throw new Error("Failed to submit answer."); + } + + const data = await response.json(); + console.log("Server feedback:", data); + + // Post-verifcation + if (data.correct === true) { // correct answer + setPoints(points + data.score) + alert("Correct answer!") + } else { + alert("Wrong answer!") + } + + // Increase current index + setIdx(idx + 1) + + } catch (error) { + console.error("Error submitting answers:", error); + } + } + // Calculate local score when submitting answer - const calculateScore = () => { + const calculateScore = (elapsedTime) => { // exponential decay calculation for points - const timeElapsed = (Date.now() - startTime) / 1000; + const timeElapsed = (elapsedTime) / 1000; const initialPoints = 100; // max possible points const decayRate = 0.05; // how quickly points decrease over time const earnedPoints = Math.round(initialPoints * Math.exp(-decayRate * timeElapsed)); @@ -94,44 +141,13 @@ function Game() {

Room {state.code}

- - - - - {isPlaying && answerOptions.length > 0 && ( -
-

Guess the Track:

- {answerOptions.map((option, index) => ( - - ))} -
- )}

Total Points: {points}

+ + {idx < state.questionSets.length ? ( + + ) : ( +

You have completed all the questions! Congratulations!

+ )}
); diff --git a/frontend/src/pages/CreateJoinRoom.js b/frontend/src/pages/CreateJoinRoom.js index 88d8b5c..d5b85e4 100644 --- a/frontend/src/pages/CreateJoinRoom.js +++ b/frontend/src/pages/CreateJoinRoom.js @@ -28,7 +28,7 @@ const CreateJoinRoom = () => { const data = await response.json(); console.log("Room created:", data); - navigate("game", {state: {code: data.code}}) + navigate("game", {state: {code: data.code, questionSets: data.questionSets}}) } catch (error) { console.error("Error creating room:", error); } diff --git a/frontend/src/pages/components/QuestionComponent.js b/frontend/src/pages/components/QuestionComponent.js new file mode 100644 index 0000000..bc991de --- /dev/null +++ b/frontend/src/pages/components/QuestionComponent.js @@ -0,0 +1,73 @@ +import React, { useState, useEffect } from "react"; + +const QuestionComponent = ({ idx, questionSet, onSubmit }) => { + const [selectedOption, setSelectedOption] = useState(null); + const [startTime, setStartTime] = useState(null); + const [elapsedTime, setElapsedTime] = useState(null); + + useEffect(() => { + // Record the start time when the component is mounted + setStartTime(Date.now()); + }, []); + + const handleOptionClick = (index) => { + console.log("index", index) + setSelectedOption(index); + + // Calculate the time taken and store it + const decisionTime = Date.now() - startTime; + setElapsedTime(decisionTime); + }; + + const handleSubmit = async() => { + if (selectedOption === null) { + alert("Please select an option before submitting."); + return; + } + + // Call the parent callback with the selected option and time taken + await onSubmit(idx, selectedOption, elapsedTime) + }; + + return ( +
+

Question {idx}

+

Choose the correct track based on the preview:

+
    + {questionSet.options.map((option, index) => ( +
  • + +
  • + ))} +
+ +
+ ); +}; + +export default QuestionComponent; diff --git a/frontend/src/pages/styles/QuestionComponent.css b/frontend/src/pages/styles/QuestionComponent.css new file mode 100644 index 0000000..e69de29 From f0624d5eca5d56a775787f3664940f93648d0121 Mon Sep 17 00:00:00 2001 From: Haotian Yi Date: Sat, 30 Nov 2024 13:02:38 -0800 Subject: [PATCH 48/51] Timeout integration --- frontend/src/Game.js | 88 +++++-------------- .../src/pages/components/QuestionComponent.js | 2 +- frontend/src/pages/components/TimeoutBar.js | 6 +- 3 files changed, 27 insertions(+), 69 deletions(-) diff --git a/frontend/src/Game.js b/frontend/src/Game.js index 00b9465..8f883f2 100644 --- a/frontend/src/Game.js +++ b/frontend/src/Game.js @@ -2,6 +2,7 @@ import React, { useState } from "react"; import "./Game.css"; import { useLocation } from "react-router-dom"; import QuestionComponent from "./pages/components/QuestionComponent"; +import TimeoutBar from "./pages/components/TimeoutBar"; const SERVER = process.env.REACT_APP_SERVER; @@ -11,10 +12,9 @@ function Game() { const [correctAnswer, setCorrectAnswer] = useState(""); // state to hold correct answer const [isPlaying, setIsPlaying] = useState(false); // state to hold current round const [audio, setAudio] = useState(null); // state to hold audio player - const [startTime, setStartTime] = useState(null); // state to hold time const [points, setPoints] = useState(0); // state to hold points - const [round, setRound] = useState(1); const [idx, setIdx] = useState(0); // state to hold which question we are playing + const [resetTrigger, setResetTrigger] = useState(0) // state to hold timeout reset trigger const location = useLocation() const { state } = location || {} @@ -22,68 +22,6 @@ function Game() { const userData = localStorage.getItem("userData"); const { userId } = JSON.parse(userData); - // play random previewurl - const playRandomPreview = async () => { - - - try { - const response = await fetch( - `${SERVER}/songModel/recommendations?genres=${selectedGenre}&limit=30` - ); - const data = await response.json(); - - // only get tracks with previewurls - const tracksWithPreview = data.tracks.filter((track) => track.preview_url); - - if (tracksWithPreview.length >= 4) { - // pick one random song as correct answer - const correctTrack = tracksWithPreview[Math.floor(Math.random() * tracksWithPreview.length)]; - // get 3 wrong answers - const incorrectTracks = tracksWithPreview - .filter((track) => track !== correctTrack) - .sort(() => 0.5 - Math.random()) - .slice(0, 3); - - // combine and shuffle all 4 answer choices - const options = [correctTrack, ...incorrectTracks].sort(() => 0.5 - Math.random()); - - setAnswerOptions(options); - setCorrectAnswer(`${correctTrack.name} - ${correctTrack.artists[0].name}`); - - // play the correct song - const newAudio = new Audio(correctTrack.preview_url); - newAudio.play(); - setAudio(newAudio); - setIsPlaying(true); - setStartTime(Date.now()); - } else { - alert("Not enough previews available for this genre."); - } - } catch (error) { - console.error("Error fetching recommendations:", error); - } - }; - - - const handleAnswerSelection = (answer) => { - // stop playing song - if (audio) { - audio.pause(); - setAudio(null); - } - setIsPlaying(false); - if (answer === correctAnswer) { - const earnedPoints = calculateScore() - setPoints((prevPoints) => prevPoints + earnedPoints); - // NEED TO MODIFY AFTER - alert(`Correct! ${earnedPoints} points.`); - } else { - alert( - `Incorrect. 0 points.` - ); - } - }; - // Handle answer submission to the server for verification const handleAnswerSubmission = async(idx, selectedOption, elapsedTime) => { const score = calculateScore(elapsedTime) // local score @@ -113,14 +51,16 @@ function Game() { // Post-verifcation if (data.correct === true) { // correct answer setPoints(points + data.score) - alert("Correct answer!") + console.log("Correct answer!") } else { - alert("Wrong answer!") + console.log("Wrong answer!") } // Increase current index setIdx(idx + 1) + // Reset timeout + setResetTrigger(resetTrigger + 1) } catch (error) { console.error("Error submitting answers:", error); } @@ -137,6 +77,17 @@ function Game() { return earnedPoints } + // Timeout handler + const handleTimeout = () => { + console.log("Timeout!") + + // Increase current index + setIdx(idx + 1) + + // Reset timeout + setResetTrigger(resetTrigger + 1) + } + return (
@@ -144,7 +95,10 @@ function Game() {

Total Points: {points}

{idx < state.questionSets.length ? ( - + <> + + + ) : (

You have completed all the questions! Congratulations!

)} diff --git a/frontend/src/pages/components/QuestionComponent.js b/frontend/src/pages/components/QuestionComponent.js index bc991de..9263231 100644 --- a/frontend/src/pages/components/QuestionComponent.js +++ b/frontend/src/pages/components/QuestionComponent.js @@ -8,7 +8,7 @@ const QuestionComponent = ({ idx, questionSet, onSubmit }) => { useEffect(() => { // Record the start time when the component is mounted setStartTime(Date.now()); - }, []); + }, [idx, questionSet]); const handleOptionClick = (index) => { console.log("index", index) diff --git a/frontend/src/pages/components/TimeoutBar.js b/frontend/src/pages/components/TimeoutBar.js index 48188e5..a3be23d 100644 --- a/frontend/src/pages/components/TimeoutBar.js +++ b/frontend/src/pages/components/TimeoutBar.js @@ -3,9 +3,13 @@ import React, { useEffect, useState } from "react"; // A timeout bar used to show the time left to answer a question // totalTime - in seconds // onTimeout - function to trigger on timeout -const TimeoutBar = ({ totalTime, onTimeout }) => { +const TimeoutBar = ({ totalTime, onTimeout, resetTrigger }) => { const [timeLeft, setTimeLeft] = useState(totalTime); + useEffect(() => { + setTimeLeft(totalTime) + }, [resetTrigger, totalTime]) + useEffect(() => { const interval = setInterval(() => { setTimeLeft((prev) => { From 2eb64ce066b8704e21388ed147c258cc367ddb34 Mon Sep 17 00:00:00 2001 From: Haotian Yi Date: Sat, 30 Nov 2024 14:16:58 -0800 Subject: [PATCH 49/51] Disable smoothing effect when resetting --- frontend/src/pages/components/TimeoutBar.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/frontend/src/pages/components/TimeoutBar.js b/frontend/src/pages/components/TimeoutBar.js index a3be23d..2af35f2 100644 --- a/frontend/src/pages/components/TimeoutBar.js +++ b/frontend/src/pages/components/TimeoutBar.js @@ -5,9 +5,15 @@ import React, { useEffect, useState } from "react"; // onTimeout - function to trigger on timeout const TimeoutBar = ({ totalTime, onTimeout, resetTrigger }) => { const [timeLeft, setTimeLeft] = useState(totalTime); + const [disableTransition, setDisableTransition] = useState(false); useEffect(() => { setTimeLeft(totalTime) + + setDisableTransition(true) // So the timebar resets immediately, css-wise + setTimeout(() => { + setDisableTransition(false); + }, 10); }, [resetTrigger, totalTime]) useEffect(() => { @@ -42,7 +48,7 @@ const TimeoutBar = ({ totalTime, onTimeout, resetTrigger }) => { width: `${percentage}%`, height: "100%", background: getColor(), - transition: "width 1s linear, background 1s linear", + transition: disableTransition ? "none" : "width 1s linear, background 1s linear", borderRadius: "5px", }} >
From 2859c235bb9134f5f02f582e85a40374440a8ed0 Mon Sep 17 00:00:00 2001 From: Haotian Yi Date: Sat, 30 Nov 2024 15:00:49 -0800 Subject: [PATCH 50/51] Basic progress tracking - not including timeout --- backend/routes/gameRouter.js | 65 +++++++++++++------ backend/utility/player.js | 16 ++++- backend/utility/room.js | 3 + frontend/src/Game.js | 16 ++--- frontend/src/pages/CreateJoinRoom.js | 7 +- frontend/src/pages/JoinRoom.js | 8 ++- .../src/pages/components/QuestionComponent.js | 2 +- 7 files changed, 84 insertions(+), 33 deletions(-) diff --git a/backend/routes/gameRouter.js b/backend/routes/gameRouter.js index bec8a3b..9df1989 100644 --- a/backend/routes/gameRouter.js +++ b/backend/routes/gameRouter.js @@ -23,23 +23,38 @@ router.post("/joinRoom", async (req, res) => { let room = Room.getRoom(code) if (room !== undefined) { // room exists - // TODO: Room operation - let player = new Player(userId) + console.log("Room %s found.", code) + console.log(room) - room.join(player) // join + let player; + + // Player exists? + if (room.players.has(userId)) { + console.log("Player %s found.", userId) + player = room.players.get(userId) + } else { + console.log("Player %s not found. Creating new instance.", userId) + player = new Player(userId) + room.join(player) // join + } // Response - const updatedQuestionSets = newRoom.questionSets.map(({ correct, ...rest }) => rest); + const updatedQuestionSets = room.questionSets.map(({ correct, ...rest }) => rest); + console.log("Question sets generated", room.questionSets) res.status(200).json({ code: code, players: Array.from(room.players), - questionSets: updatedQuestionSets // hide correct answers + questionSets: updatedQuestionSets, // hide correct answers + progress: player.progress, // idx of question answered + score: player.score // player's current score }) } else { - res.status(400) // bad request, room doesn't exist + console.log("Room %s not found, bad request.", code) + res.status(400) // bad request, room doesn't exist + throw new Error("Room doesn't exist") // TODO: we can choose to create room with specific code } @@ -87,21 +102,31 @@ router.post("/submitAnswer", async (req, res) => { let room = Room.getRoom(code) let questionSet = room.questionSets[idx] // TODO: error handling - let correct = questionSet.isCorrect(choice) - if (correct) { // correct answer - let player = room.players.get(userId) - player.addScore(score) - console.log("Room %s, player %s add %d score", code, userId, score) - } else { // wrong answer - let player = room.players.get(userId) - player.update() - console.log("Room %s, player %s wrong answer", code, userId) - } + let player = room.players.get(userId) + if (player.answered(idx)) { // duplicate answer + player.pulse() + throw new Error("Duplicate submission") + } else { + let correct = questionSet.isCorrect(choice) + + if (correct) { // correct answer + let player = room.players.get(userId) + player.addScore(score) + console.log("Room %s, player %s add %d score", code, userId, score) + } else { // wrong answer + let player = room.players.get(userId) + player.pulse() + console.log("Room %s, player %s wrong answer", code, userId) + } + + player.updateProgress(idx) // player has answered question up to idx + console.log("Player %s progress updated to %d", userId, player.progress) - res.status(200).json({ - correct: correct, - score: score - }) + res.status(200).json({ + correct: correct, + score: score + }) + } } catch(error) { console.error("Error verifying answer:", error); diff --git a/backend/utility/player.js b/backend/utility/player.js index a3d50e3..9cbaf4c 100644 --- a/backend/utility/player.js +++ b/backend/utility/player.js @@ -3,6 +3,7 @@ class Player { // player is specific to room constructor(userId) { this.id = userId // unique identifier this.score = 0 // starts with 0 score + this.progress = 0 // idx of question set the player has reached this.lastUpdate = Date.now() // last update } @@ -14,11 +15,22 @@ class Player { // player is specific to room // Add score addScore(delta) { this.score += delta - this.update() + this.pulse() + } + + // Answer question + updateProgress(idx) { + this.progress = idx + 1 + this.pulse() + } + + // Update the index of question set the player has reached + answered(idx) { + return idx < this.progress } // Pulse - update lastUpdate - update() { + pulse() { this.lastUpdate = Date.now() } } diff --git a/backend/utility/room.js b/backend/utility/room.js index b6c79dc..8120809 100644 --- a/backend/utility/room.js +++ b/backend/utility/room.js @@ -103,6 +103,9 @@ class Room { /* Clean-Up */ // TODO: cleanup methods + cleanup() { + + } /* Getters */ get playerCount() { diff --git a/frontend/src/Game.js b/frontend/src/Game.js index 8f883f2..e501793 100644 --- a/frontend/src/Game.js +++ b/frontend/src/Game.js @@ -7,21 +7,21 @@ import TimeoutBar from "./pages/components/TimeoutBar"; const SERVER = process.env.REACT_APP_SERVER; function Game() { + const location = useLocation() + const { state } = location || {} + + const userData = localStorage.getItem("userData"); + const { userId } = JSON.parse(userData); + const [selectedGenre, setSelectedGenre] = useState(""); // state to hold genre const [answerOptions, setAnswerOptions] = useState([]); // state to hold 4 answer options const [correctAnswer, setCorrectAnswer] = useState(""); // state to hold correct answer const [isPlaying, setIsPlaying] = useState(false); // state to hold current round const [audio, setAudio] = useState(null); // state to hold audio player - const [points, setPoints] = useState(0); // state to hold points - const [idx, setIdx] = useState(0); // state to hold which question we are playing + const [points, setPoints] = useState(state.score); // state to hold points + const [idx, setIdx] = useState(state.progress); // state to hold which question we are playing const [resetTrigger, setResetTrigger] = useState(0) // state to hold timeout reset trigger - const location = useLocation() - const { state } = location || {} - - const userData = localStorage.getItem("userData"); - const { userId } = JSON.parse(userData); - // Handle answer submission to the server for verification const handleAnswerSubmission = async(idx, selectedOption, elapsedTime) => { const score = calculateScore(elapsedTime) // local score diff --git a/frontend/src/pages/CreateJoinRoom.js b/frontend/src/pages/CreateJoinRoom.js index d5b85e4..9c45303 100644 --- a/frontend/src/pages/CreateJoinRoom.js +++ b/frontend/src/pages/CreateJoinRoom.js @@ -28,7 +28,12 @@ const CreateJoinRoom = () => { const data = await response.json(); console.log("Room created:", data); - navigate("game", {state: {code: data.code, questionSets: data.questionSets}}) + navigate("game", {state: { + code: data.code, + questionSets: data.questionSets, + progress: 0, + score: 0 + }}) } catch (error) { console.error("Error creating room:", error); } diff --git a/frontend/src/pages/JoinRoom.js b/frontend/src/pages/JoinRoom.js index 4c1ff24..a8e2042 100644 --- a/frontend/src/pages/JoinRoom.js +++ b/frontend/src/pages/JoinRoom.js @@ -32,8 +32,14 @@ const JoinRoom = () => { const data = await response.json(); console.log("Joined room:", data); - navigate("/room/game", {state: {code: data.code}}) + navigate("/room/game", {state: { + code: data.code, + questionSets: data.questionSets, + progress: data.progress, + score: data.score + }}) } catch (error) { + alert("Failed to join room") console.error("Error joining room:", error); } } diff --git a/frontend/src/pages/components/QuestionComponent.js b/frontend/src/pages/components/QuestionComponent.js index 9263231..229c1f9 100644 --- a/frontend/src/pages/components/QuestionComponent.js +++ b/frontend/src/pages/components/QuestionComponent.js @@ -31,7 +31,7 @@ const QuestionComponent = ({ idx, questionSet, onSubmit }) => { return (
-

Question {idx}

+

Question {idx + 1}

Choose the correct track based on the preview:

    {questionSet.options.map((option, index) => ( From 1588f41e20809e5cbf812fce51bb977a766dfd16 Mon Sep 17 00:00:00 2001 From: Haotian Yi Date: Sat, 30 Nov 2024 16:08:36 -0800 Subject: [PATCH 51/51] Automatic cleanup of inactive rooms --- backend/server.js | 4 ++++ backend/utility/player.js | 14 +++++++---- backend/utility/room.js | 49 ++++++++++++++++++++++++++++++++++++++- 3 files changed, 62 insertions(+), 5 deletions(-) diff --git a/backend/server.js b/backend/server.js index 78e49da..5082b77 100644 --- a/backend/server.js +++ b/backend/server.js @@ -23,6 +23,7 @@ const server = http.createServer(app); const db = require('./db.js'); // connect to database const matchModel = require('./models/matchModel.js'); +const { Room } = require('./utility/room.js'); // Routes // --- Spotify access token @@ -48,5 +49,8 @@ cron.schedule("0 0 * * *", async () => { timezone: "America/Los_Angeles", }); +// Room Game Cleanup +Room.periodicCleanup() // by default 60 seconds + // uncomment this and run server to generate new dailychallenge questions //runCronJob(); \ No newline at end of file diff --git a/backend/utility/player.js b/backend/utility/player.js index 9cbaf4c..fa18aaf 100644 --- a/backend/utility/player.js +++ b/backend/utility/player.js @@ -1,10 +1,16 @@ // Player model used in Rooms class Player { // player is specific to room + static STATE = { + ACTIVE: 1, + INACTIVE: 0 + } + constructor(userId) { - this.id = userId // unique identifier - this.score = 0 // starts with 0 score - this.progress = 0 // idx of question set the player has reached - this.lastUpdate = Date.now() // last update + this.id = userId // unique identifier + this.score = 0 // starts with 0 score + this.progress = 0 // idx of question set the player has reached + this.state = Player.STATE.ACTIVE // 0 - inactive, 1 - active + this.lastUpdate = Date.now() // last update } // Is the player expired? diff --git a/backend/utility/room.js b/backend/utility/room.js index 8120809..fc28223 100644 --- a/backend/utility/room.js +++ b/backend/utility/room.js @@ -1,4 +1,5 @@ const { RoomModel } = require("../db") +const { Player } = require("./player") const GAME_STATES = { OPEN: "open", @@ -7,6 +8,18 @@ const GAME_STATES = { CLOSED: "closed" } +class ExpirationHandler { + static INSTANT_EXPIRATION = 5 * 1000 // 5 seconds, note that this causes error since it is shorter than a game session + static QUICK_EXPIRATION = 5 * 60 * 1000 // 5 minutes + static MID_EXPIRATION = 10 * 60 * 1000 // 10 minutes + static LONG_EXPIRATION = 15 * 60 * 1000 // 15 minutes + + static getExpiration() { + // TODO: conditional + return ExpirationHandler.MID_EXPIRATION + } +} + const ROOM_CODE_DIGITS = 4 // room code is 4-digits long // Room where games are held @@ -17,6 +30,8 @@ class Room { static codePair = {} + static cleanupTimeoutId = null // storing timeout for periodic cleanup + constructor() { this.id = ++Room.gameId // id this.state = GAME_STATES.OPEN // game is by default open @@ -104,7 +119,39 @@ class Room { /* Clean-Up */ // TODO: cleanup methods cleanup() { - + // Update active/inactive + for (let [id, player] of this.players) { + if ((Date.now() - player.lastUpdate) > ExpirationHandler.getExpiration()) { // expires + console.log("Player %s set to inactive.", id) + player.state = Player.STATE.INACTIVE + } + } + + // Scan for inactivity + for (let [id, player] of this.players) { + if (player.state == Player.STATE.ACTIVE) return; + } + console.log("Room %s now closed due to inactivity.", this.code) + + // Close the room since all players are inactive for a while + this.close() + } + + // Static cleanup + // -- period: in seconds + static periodicCleanup(period=60) { + // if (Room.cleanupTimeoutId !== null) { + // return // period cleanup already in progress + // } + + Room.cleanupTimeoutId = setTimeout(() => { + console.log("Room: periodic cleanup (%ds) begins", period) + for (let [code, room] of Object.entries(Room.codePair)) { + room.cleanup() + } + console.log("Room: periodic cleanup (%ds) ends", period) + Room.periodicCleanup(period) + }, period * 1000) } /* Getters */