From 9488bde0a6c989f0e47e1a20fba1f766635f2e64 Mon Sep 17 00:00:00 2001 From: Luke Schlangen Date: Thu, 24 Aug 2023 12:21:31 -0500 Subject: [PATCH] Feature/remove server state (#99) * feat: remove server timeout progression * feat: clean nudge-game route * feat: add planned game nudge on time getting to 0 * feat: convert create-game from api to action * feat: convert nudge game from api route to action * feat: remove unknownParser from actions * feat: convert create game api to action * refactor: move start-game api to action * refactor: convert update answer api to action * refactor: change exit-game api to action * refactor: move actions out of folders * refactor: remove custom zod-parser * refactor: remove time-calculator * feat: improve visual cues for correct answers --- .../game/[gameId]/page.tsx | 2 +- .../route.ts => actions/create-game.ts} | 48 +++++----- app-dev/party-game/app/actions/delete-game.ts | 41 +++++++++ app-dev/party-game/app/actions/exit-game.ts | 37 ++++++++ app-dev/party-game/app/actions/join-game.ts | 45 ++++++++++ app-dev/party-game/app/actions/nudge-game.ts | 57 ++++++++++++ app-dev/party-game/app/actions/start-game.ts | 45 ++++++++++ .../party-game/app/actions/update-answer.ts | 48 ++++++++++ .../party-game/app/api/delete-game/route.ts | 56 ------------ app-dev/party-game/app/api/exit-game/route.ts | 47 ---------- app-dev/party-game/app/api/join-game/route.ts | 48 ---------- .../party-game/app/api/nudge-game/route.ts | 65 -------------- .../party-game/app/api/start-game/route.ts | 88 ------------------- .../party-game/app/api/update-answer/route.ts | 74 ---------------- .../app/components/border-countdown-timer.tsx | 78 ++++++++-------- .../app/components/create-game-form.tsx | 50 +++++++---- .../app/components/delete-game-button.tsx | 21 ++--- .../app/components/exit-game-button.tsx | 20 ++--- app-dev/party-game/app/components/lobby.tsx | 14 ++- app-dev/party-game/app/components/navbar.tsx | 2 +- .../app/components/question-panel.tsx | 54 +++++++----- .../app/components/share-link-panel.tsx | 2 +- .../app/components/start-game-button.tsx | 19 ++-- app-dev/party-game/app/hooks/use-game.ts | 23 ++--- .../party-game/app/hooks/use-scoreboard.ts | 2 +- app-dev/party-game/app/lib/name-generator.ts | 14 ++- app-dev/party-game/app/lib/time-calculator.ts | 59 ------------- app-dev/party-game/app/lib/zod-parser.ts | 41 --------- app-dev/party-game/app/types/index.ts | 7 +- app-dev/party-game/next.config.js | 7 +- 30 files changed, 449 insertions(+), 665 deletions(-) rename app-dev/party-game/app/{api/create-game/route.ts => actions/create-game.ts} (56%) create mode 100644 app-dev/party-game/app/actions/delete-game.ts create mode 100644 app-dev/party-game/app/actions/exit-game.ts create mode 100644 app-dev/party-game/app/actions/join-game.ts create mode 100644 app-dev/party-game/app/actions/nudge-game.ts create mode 100644 app-dev/party-game/app/actions/start-game.ts create mode 100644 app-dev/party-game/app/actions/update-answer.ts delete mode 100644 app-dev/party-game/app/api/delete-game/route.ts delete mode 100644 app-dev/party-game/app/api/exit-game/route.ts delete mode 100644 app-dev/party-game/app/api/join-game/route.ts delete mode 100644 app-dev/party-game/app/api/nudge-game/route.ts delete mode 100644 app-dev/party-game/app/api/start-game/route.ts delete mode 100644 app-dev/party-game/app/api/update-answer/route.ts delete mode 100644 app-dev/party-game/app/lib/time-calculator.ts delete mode 100644 app-dev/party-game/app/lib/zod-parser.ts diff --git a/app-dev/party-game/app/(authenticated-pages)/game/[gameId]/page.tsx b/app-dev/party-game/app/(authenticated-pages)/game/[gameId]/page.tsx index 72ce68e5..a4f5fecd 100644 --- a/app-dev/party-game/app/(authenticated-pages)/game/[gameId]/page.tsx +++ b/app-dev/party-game/app/(authenticated-pages)/game/[gameId]/page.tsx @@ -47,7 +47,7 @@ export default function GamePage() { )} {gameRef && <> {isShowingQuestion && ()} - {game.state === gameStates.NOT_STARTED && ()} + {game.state === gameStates.NOT_STARTED && ()} } ); diff --git a/app-dev/party-game/app/api/create-game/route.ts b/app-dev/party-game/app/actions/create-game.ts similarity index 56% rename from app-dev/party-game/app/api/create-game/route.ts rename to app-dev/party-game/app/actions/create-game.ts index b46635cc..1393aa05 100644 --- a/app-dev/party-game/app/api/create-game/route.ts +++ b/app-dev/party-game/app/actions/create-game.ts @@ -14,48 +14,40 @@ * limitations under the License. */ -import {unknownParser, unknownValidator} from '@/app/lib/zod-parser'; -import {gamesRef, questionsRef} from '@/app/lib/firebase-server-initialization'; +'use server'; + +import {app, gamesRef, questionsRef} from '@/app/lib/firebase-server-initialization'; import {generateName} from '@/app/lib/name-generator'; -import {getAuthenticatedUser} from '@/app/lib/server-side-auth'; -import {Game, Question, QuestionSchema, gameStates} from '@/app/types'; +import {Game, GameSettings, Question, QuestionSchema, gameStates} from '@/app/types'; import {QueryDocumentSnapshot, Timestamp} from 'firebase-admin/firestore'; -import {NextRequest, NextResponse} from 'next/server'; -import {authenticationFailedResponse} from '@/app/lib/authentication-failed-response'; import {GameSettingsSchema} from '@/app/types'; -import {badRequestResponse} from '@/app/lib/bad-request-response'; - -export async function POST(request: NextRequest) { - let authUser; - try { - authUser = await getAuthenticatedUser(request); - } catch (error) { - return authenticationFailedResponse(); - } +import {getAuth} from 'firebase-admin/auth'; - // Validate request - const body = await request.json(); - const errorMessage = unknownValidator(body, GameSettingsSchema); - if (errorMessage) return badRequestResponse({errorMessage}); - const {timePerQuestion, timePerAnswer} = unknownParser(body, GameSettingsSchema); +export async function createGameAction({gameSettings, token}: {gameSettings: GameSettings, token: string}): Promise<{gameId: string}> { + const authUser = await getAuth(app).verifyIdToken(token); + // Parse request (throw an error if not correct) + const {timePerQuestion, timePerAnswer} = GameSettingsSchema.parse(gameSettings); const querySnapshot = await questionsRef.get(); const validQuestionsArray = querySnapshot.docs.reduce((agg: Question[], doc: QueryDocumentSnapshot) => { - const question = doc.data(); - const errorMessage = unknownValidator(question, QuestionSchema); - if (errorMessage) { + let question = doc.data(); + try { + question = QuestionSchema.parse(question); + return [...agg, question]; + } catch (error) { console.warn(`WARNING: The question "${question?.prompt}" [Firestore ID: ${doc.id}] has an issue and will not be added to the game.`); return agg; } - return [...agg, question]; }, []); + + // convert array to object for Firebase const questions = {...validQuestionsArray}; - if (!authUser) throw new Error('User must be signed in to start game'); + // create game with server endpoint const leader = { - displayName: generateName(), + displayName: generateName(authUser.uid), uid: authUser.uid, }; @@ -74,5 +66,7 @@ export async function POST(request: NextRequest) { const gameRef = await gamesRef.add(newGame); - return NextResponse.json({gameId: gameRef.id}, {status: 200}); + if (gameRef.id) return {gameId: gameRef.id}; + + throw new Error('no gameId returned in the response'); } diff --git a/app-dev/party-game/app/actions/delete-game.ts b/app-dev/party-game/app/actions/delete-game.ts new file mode 100644 index 00000000..2a5ded30 --- /dev/null +++ b/app-dev/party-game/app/actions/delete-game.ts @@ -0,0 +1,41 @@ +/** + * Copyright 2023 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use server'; + +import {app, gamesRef} from '@/app/lib/firebase-server-initialization'; +import {GameIdSchema} from '@/app/types'; +import {getAuth} from 'firebase-admin/auth'; + +export async function deleteGameAction({gameId, token}: {gameId: string, token: string}) { + // Authenticate user + const authUser = await getAuth(app).verifyIdToken(token); + + // Parse request (throw an error if not correct) + GameIdSchema.parse(gameId); + + const gameRef = await gamesRef.doc(gameId); + const gameDoc = await gameRef.get(); + const game = gameDoc.data(); + + if (game.leader.uid !== authUser.uid) { + // Respond with JSON indicating no game was found + throw new Error('Only the leader of this game may delete this game.'); + } + + // update database to delete the game + await gameRef.delete(); +} diff --git a/app-dev/party-game/app/actions/exit-game.ts b/app-dev/party-game/app/actions/exit-game.ts new file mode 100644 index 00000000..a8d65cab --- /dev/null +++ b/app-dev/party-game/app/actions/exit-game.ts @@ -0,0 +1,37 @@ +/** + * Copyright 2023 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use server'; + +import {app, gamesRef} from '@/app/lib/firebase-server-initialization'; +import {FieldValue} from 'firebase-admin/firestore'; +import {GameIdSchema} from '@/app/types'; +import {getAuth} from 'firebase-admin/auth'; + +export async function exitGameAction({gameId, token}: {gameId: string, token: string}) { + // Authenticate user + const authUser = await getAuth(app).verifyIdToken(token); + + // Parse request (throw an error if not correct) + GameIdSchema.parse(gameId); + + const gameRef = await gamesRef.doc(gameId); + + // update database to exit the game + await gameRef.update({ + [`players.${authUser.uid}`]: FieldValue.delete(), + }); +} diff --git a/app-dev/party-game/app/actions/join-game.ts b/app-dev/party-game/app/actions/join-game.ts new file mode 100644 index 00000000..65647528 --- /dev/null +++ b/app-dev/party-game/app/actions/join-game.ts @@ -0,0 +1,45 @@ +/** + * Copyright 2023 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use server'; + +import {app, gamesRef} from '@/app/lib/firebase-server-initialization'; +import {generateName} from '@/app/lib/name-generator'; +import {GameIdSchema} from '@/app/types'; +import {getAuth} from 'firebase-admin/auth'; + +export async function joinGameAction({gameId, token}: {gameId: string, token: string}) { + // Authenticate user + const authUser = await getAuth(app).verifyIdToken(token); + + // Parse request (throw an error if not correct) + GameIdSchema.parse(gameId); + + const gameRef = await gamesRef.doc(gameId); + const gameDoc = await gameRef.get(); + const game = gameDoc.data(); + const playerIdList = Object.keys(game.players); + if (playerIdList.includes(authUser.uid)) return; + if (game.leader.uid === authUser.uid) { + // Respond with JSON indicating no game was found + throw new Error('The game leader may not be a player.'); + } + + // update database to join the game + await gameRef.update({ + [`players.${authUser.uid}`]: generateName(authUser.uid), + }); +} diff --git a/app-dev/party-game/app/actions/nudge-game.ts b/app-dev/party-game/app/actions/nudge-game.ts new file mode 100644 index 00000000..ba58ae70 --- /dev/null +++ b/app-dev/party-game/app/actions/nudge-game.ts @@ -0,0 +1,57 @@ +/** + * Copyright 2023 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use server'; + +import {gamesRef} from '@/app/lib/firebase-server-initialization'; +import {GameIdSchema, gameStates} from '@/app/types'; +import {Timestamp} from 'firebase-admin/firestore'; + +export async function nudgeGame({gameId}: {gameId: string}) { + // Validate request + // Will throw an error if not a string + GameIdSchema.parse(gameId); + + const gameRef = await gamesRef.doc(gameId); + const gameDoc = await gameRef.get(); + const game = gameDoc.data(); + + // force the game state to move to where the game should be + + const timeElapsedInMillis = Timestamp.now().toMillis() - game.startTime.seconds * 1000; + const timeElapsed = timeElapsedInMillis / 1000; + const timePerQuestionAndAnswer = game.timePerQuestion + game.timePerAnswer; + + const totalNumberOfQuestions = Object.keys(game.questions).length; + const finalQuestionIndex = totalNumberOfQuestions - 1; + const correctQuestionIndex = Math.floor(timeElapsed / timePerQuestionAndAnswer); + if (correctQuestionIndex > finalQuestionIndex) { + await gameRef.update({ + state: gameStates.GAME_OVER, + currentQuestionIndex: finalQuestionIndex, + }); + return; + } + + const timeThisQuestionStarted = correctQuestionIndex * timePerQuestionAndAnswer; + const shouldBeAcceptingAnswers = timeElapsed - timeThisQuestionStarted < game.timePerQuestion; + const correctState = shouldBeAcceptingAnswers ? gameStates.AWAITING_PLAYER_ANSWERS : gameStates.SHOWING_CORRECT_ANSWERS; + + await gameRef.update({ + state: correctState, + currentQuestionIndex: correctQuestionIndex, + }); +} diff --git a/app-dev/party-game/app/actions/start-game.ts b/app-dev/party-game/app/actions/start-game.ts new file mode 100644 index 00000000..1ec3d232 --- /dev/null +++ b/app-dev/party-game/app/actions/start-game.ts @@ -0,0 +1,45 @@ +/** + * Copyright 2023 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use server'; + +import {app, gamesRef} from '@/app/lib/firebase-server-initialization'; +import {GameIdSchema, gameStates} from '@/app/types'; +import {FieldValue} from 'firebase-admin/firestore'; +import {getAuth} from 'firebase-admin/auth'; + +export async function startGameAction({gameId, token}: {gameId: string, token: string}) { + // Authenticate user + const authUser = await getAuth(app).verifyIdToken(token); + + // Parse request (throw an error if not correct) + GameIdSchema.parse(gameId); + + const gameRef = await gamesRef.doc(gameId); + const gameDoc = await gameRef.get(); + const game = gameDoc.data(); + + if (game.leader.uid !== authUser.uid) { + // Respond with JSON indicating no game was found + throw new Error('Only the leader of this game may start this game.'); + } + + // update database to start the game + await gameRef.update({ + state: gameStates.AWAITING_PLAYER_ANSWERS, + startTime: FieldValue.serverTimestamp(), + }); +} diff --git a/app-dev/party-game/app/actions/update-answer.ts b/app-dev/party-game/app/actions/update-answer.ts new file mode 100644 index 00000000..f6248678 --- /dev/null +++ b/app-dev/party-game/app/actions/update-answer.ts @@ -0,0 +1,48 @@ +/** + * Copyright 2023 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use server'; + +import {app, gamesRef} from '@/app/lib/firebase-server-initialization'; +import {GameIdSchema, gameStates} from '@/app/types'; +import {getAuth} from 'firebase-admin/auth'; +import {z} from 'zod'; + +export async function updateAnswerAction({gameId, answerSelection, token}: {gameId: string, answerSelection: boolean[], token: string}) { + // Authenticate user + const authUser = await getAuth(app).verifyIdToken(token); + + // Parse request (throw an error if not correct) + GameIdSchema.parse(gameId); + + const gameRef = await gamesRef.doc(gameId); + const gameDoc = await gameRef.get(); + const game = gameDoc.data(); + + if (game.state !== gameStates.AWAITING_PLAYER_ANSWERS) { + return new Error(`Answering is not allowed during ${game.state}.`); + } + + // answerSelection must be an array of booleans as long as the game question answers + const currentQuestion = game.questions[game.currentQuestionIndex]; + const ValidAnswerSchema = z.array(z.boolean()).length(currentQuestion.answers.length); + ValidAnswerSchema.parse(answerSelection); + + // update database to start the game + await gameRef.update({ + [`questions.${game.currentQuestionIndex}.playerGuesses.${authUser.uid}`]: answerSelection, + }); +} diff --git a/app-dev/party-game/app/api/delete-game/route.ts b/app-dev/party-game/app/api/delete-game/route.ts deleted file mode 100644 index c6324ccb..00000000 --- a/app-dev/party-game/app/api/delete-game/route.ts +++ /dev/null @@ -1,56 +0,0 @@ -/** - * Copyright 2023 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import {unknownParser, unknownValidator} from '@/app/lib/zod-parser'; -import {gamesRef} from '@/app/lib/firebase-server-initialization'; -import {getAuthenticatedUser} from '@/app/lib/server-side-auth'; -import {NextRequest, NextResponse} from 'next/server'; -import {GameIdObjectSchema} from '@/app/types'; -import {badRequestResponse} from '@/app/lib/bad-request-response'; -import {authenticationFailedResponse} from '@/app/lib/authentication-failed-response'; - -export async function POST(request: NextRequest) { - // Authenticate user - let authUser; - try { - authUser = await getAuthenticatedUser(request); - } catch (error) { - return authenticationFailedResponse(); - } - - // Validate request - const body = await request.json(); - const errorMessage = unknownValidator(body, GameIdObjectSchema); - if (errorMessage) return badRequestResponse({errorMessage}); - const {gameId} = unknownParser(body, GameIdObjectSchema); - - const gameRef = await gamesRef.doc(gameId); - const gameDoc = await gameRef.get(); - const game = gameDoc.data(); - - if (game.leader.uid !== authUser.uid) { - // Respond with JSON indicating no game was found - return new NextResponse( - JSON.stringify({success: false, message: 'no game found'}), - {status: 404, headers: {'content-type': 'application/json'}} - ); - } - - // update database to delete the game - await gameRef.delete(); - - return NextResponse.json('successfully joined game', {status: 200}); -} diff --git a/app-dev/party-game/app/api/exit-game/route.ts b/app-dev/party-game/app/api/exit-game/route.ts deleted file mode 100644 index 11dd8f5b..00000000 --- a/app-dev/party-game/app/api/exit-game/route.ts +++ /dev/null @@ -1,47 +0,0 @@ -/** - * Copyright 2023 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import {unknownParser, unknownValidator} from '@/app/lib/zod-parser'; -import {gamesRef} from '@/app/lib/firebase-server-initialization'; -import {getAuthenticatedUser} from '@/app/lib/server-side-auth'; -import {FieldValue} from 'firebase-admin/firestore'; -import {NextRequest, NextResponse} from 'next/server'; -import {badRequestResponse} from '@/app/lib/bad-request-response'; -import {GameIdObjectSchema} from '@/app/types'; -import {authenticationFailedResponse} from '@/app/lib/authentication-failed-response'; - -export async function POST(request: NextRequest) { - let authUser; - try { - authUser = await getAuthenticatedUser(request); - } catch (error) { - return authenticationFailedResponse(); - } - - // Validate request - const body = await request.json(); - const errorMessage = unknownValidator(body, GameIdObjectSchema); - if (errorMessage) return badRequestResponse({errorMessage}); - const {gameId} = unknownParser(body, GameIdObjectSchema); - const gameRef = await gamesRef.doc(gameId); - - // update database to exit the game - await gameRef.update({ - [`players.${authUser.uid}`]: FieldValue.delete(), - }); - - return NextResponse.json('successfully joined game', {status: 200}); -} diff --git a/app-dev/party-game/app/api/join-game/route.ts b/app-dev/party-game/app/api/join-game/route.ts deleted file mode 100644 index c36f4e82..00000000 --- a/app-dev/party-game/app/api/join-game/route.ts +++ /dev/null @@ -1,48 +0,0 @@ -/** - * Copyright 2023 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import {unknownParser, unknownValidator} from '@/app/lib/zod-parser'; -import {gamesRef} from '@/app/lib/firebase-server-initialization'; -import {generateName} from '@/app/lib/name-generator'; -import {getAuthenticatedUser} from '@/app/lib/server-side-auth'; -import {NextRequest, NextResponse} from 'next/server'; -import {badRequestResponse} from '@/app/lib/bad-request-response'; -import {GameIdObjectSchema} from '@/app/types'; -import {authenticationFailedResponse} from '@/app/lib/authentication-failed-response'; - -export async function POST(request: NextRequest) { - // Authenticate user - let authUser; - try { - authUser = await getAuthenticatedUser(request); - } catch (error) { - return authenticationFailedResponse(); - } - - // Validate request - const body = await request.json(); - const errorMessage = unknownValidator(body, GameIdObjectSchema); - if (errorMessage) return badRequestResponse({errorMessage}); - const {gameId} = unknownParser(body, GameIdObjectSchema); - const gameRef = await gamesRef.doc(gameId); - - // update database to join the game - await gameRef.update({ - [`players.${authUser.uid}`]: generateName(), - }); - - return NextResponse.json('successfully joined game', {status: 200}); -} diff --git a/app-dev/party-game/app/api/nudge-game/route.ts b/app-dev/party-game/app/api/nudge-game/route.ts deleted file mode 100644 index 767aa2aa..00000000 --- a/app-dev/party-game/app/api/nudge-game/route.ts +++ /dev/null @@ -1,65 +0,0 @@ -/** - * Copyright 2023 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import {unknownParser, unknownValidator} from '@/app/lib/zod-parser'; -import {gamesRef} from '@/app/lib/firebase-server-initialization'; -import {timeCalculator} from '@/app/lib/time-calculator'; -import {gameStates} from '@/app/types'; -import {Timestamp} from 'firebase-admin/firestore'; -import {NextRequest, NextResponse} from 'next/server'; -import {badRequestResponse} from '@/app/lib/bad-request-response'; -import {GameIdObjectSchema} from '@/app/types'; - -export async function POST(request: NextRequest) { - // Validate request - const body = await request.json(); - const errorMessage = unknownValidator(body, GameIdObjectSchema); - if (errorMessage) return badRequestResponse({errorMessage}); - const {gameId} = unknownParser(body, GameIdObjectSchema); - - const gameRef = await gamesRef.doc(gameId); - const gameDoc = await gameRef.get(); - const game = gameDoc.data(); - - // force the game state to move to where the game should be - const { - timeElapsed, - timePerQuestionAndAnswer, - isTimeToShowAnswer, - isTimeToStartNextQuestion, - } = timeCalculator({currentTimeInMillis: Timestamp.now().toMillis(), game}); - - const totalNumberOfQuestions = Object.keys(game.questions).length; - - if (isTimeToStartNextQuestion) { - if (game.currentQuestionIndex < totalNumberOfQuestions - 1) { - await gameRef.update({ - state: gameStates.AWAITING_PLAYER_ANSWERS, - currentQuestionIndex: Math.min(Math.floor(timeElapsed / timePerQuestionAndAnswer), totalNumberOfQuestions - 1), - }); - } else { - await gameRef.update({ - state: gameStates.GAME_OVER, - }); - } - } else if (isTimeToShowAnswer) { - await gameRef.update({ - state: gameStates.SHOWING_CORRECT_ANSWERS, - }); - } - - return NextResponse.json('successfully nudged game', {status: 200}); -} diff --git a/app-dev/party-game/app/api/start-game/route.ts b/app-dev/party-game/app/api/start-game/route.ts deleted file mode 100644 index ae57bde1..00000000 --- a/app-dev/party-game/app/api/start-game/route.ts +++ /dev/null @@ -1,88 +0,0 @@ -/** - * Copyright 2023 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import {unknownParser, unknownValidator} from '@/app/lib/zod-parser'; -import {gamesRef} from '@/app/lib/firebase-server-initialization'; -import {getAuthenticatedUser} from '@/app/lib/server-side-auth'; -import {GameIdObjectSchema, gameStates} from '@/app/types'; -import {FieldValue} from 'firebase-admin/firestore'; -import {NextRequest, NextResponse} from 'next/server'; -import {badRequestResponse} from '@/app/lib/bad-request-response'; -import {authenticationFailedResponse} from '@/app/lib/authentication-failed-response'; - -export async function POST(request: NextRequest) { - // Authenticate user - let authUser; - try { - authUser = await getAuthenticatedUser(request); - } catch (error) { - return authenticationFailedResponse(); - } - - // Validate request - const body = await request.json(); - const errorMessage = unknownValidator(body, GameIdObjectSchema); - if (errorMessage) return badRequestResponse({errorMessage}); - const {gameId} = unknownParser(body, GameIdObjectSchema); - - const gameRef = await gamesRef.doc(gameId); - const gameDoc = await gameRef.get(); - const game = gameDoc.data(); - - if (game.leader.uid !== authUser.uid) { - // Respond with JSON indicating an error message - return new NextResponse( - JSON.stringify({success: false, message: 'no game found'}), - {status: 404, headers: {'content-type': 'application/json'}} - ); - } - - // update database to start the game - await gameRef.update({ - state: gameStates.AWAITING_PLAYER_ANSWERS, - startTime: FieldValue.serverTimestamp(), - }); - - // start automatic question progression - async function showQuestion() { - await new Promise((resolve) => setTimeout(resolve, game.timePerQuestion * 1000)); - await gameRef.update({ - state: gameStates.SHOWING_CORRECT_ANSWERS, - }); - showAnswers(); - } - - async function showAnswers() { - const gameDoc = await gameRef.get(); - const game = gameDoc.data(); - await new Promise((resolve) => setTimeout(resolve, game.timePerAnswer * 1000)); - if (game.currentQuestionIndex < Object.keys(game.questions).length - 1) { - await gameRef.update({ - state: gameStates.AWAITING_PLAYER_ANSWERS, - currentQuestionIndex: game.currentQuestionIndex + 1, - }); - showQuestion(); - } else { - await gameRef.update({ - state: gameStates.GAME_OVER, - }); - } - } - - showQuestion(); // starts first question - - return NextResponse.json('successfully started game', {status: 200}); -} diff --git a/app-dev/party-game/app/api/update-answer/route.ts b/app-dev/party-game/app/api/update-answer/route.ts deleted file mode 100644 index 0041700e..00000000 --- a/app-dev/party-game/app/api/update-answer/route.ts +++ /dev/null @@ -1,74 +0,0 @@ -/** - * Copyright 2023 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import {unknownParser, unknownValidator} from '@/app/lib/zod-parser'; -import {gamesRef} from '@/app/lib/firebase-server-initialization'; -import {getAuthenticatedUser} from '@/app/lib/server-side-auth'; -import {gameStates} from '@/app/types'; -import {NextRequest, NextResponse} from 'next/server'; -import {AnswerSelectionWithGameIdSchema} from '@/app/types'; -import {badRequestResponse} from '@/app/lib/bad-request-response'; -import {authenticationFailedResponse} from '@/app/lib/authentication-failed-response'; - -export async function POST(request: NextRequest) { - let authUser; - try { - authUser = await getAuthenticatedUser(request); - } catch (error) { - return authenticationFailedResponse(); - } - - // Validate request - const body = await request.json(); - const errorMessage = unknownValidator(body, AnswerSelectionWithGameIdSchema); - if (errorMessage) return badRequestResponse({errorMessage}); - const {gameId, answerSelection} = unknownParser(body, AnswerSelectionWithGameIdSchema); - - const gameRef = await gamesRef.doc(gameId); - const gameDoc = await gameRef.get(); - const game = gameDoc.data(); - - if (game.state !== gameStates.AWAITING_PLAYER_ANSWERS) { - // Respond with JSON indicating an error message - return new NextResponse( - JSON.stringify({success: false, message: `answering is not allowed during ${game.state}`}), - {status: 403, headers: {'content-type': 'application/json'}} - ); - } - - - // answerSelection must be an array of booleans as long as the game question answers - const currentQuestion = game.questions[game.currentQuestionIndex]; - const isCorrectLength = answerSelection.length === currentQuestion.answers.length; - - const isBoolean = (value: boolean) => value === true || value === false; - const answerSelectionIsValid = answerSelection.every(isBoolean); - - if (!isCorrectLength || !answerSelectionIsValid) { - // Respond with JSON indicating an error message - return new NextResponse( - JSON.stringify({success: false, message: 'answer selection must be an array of booleans as long as the game question answers'}), - {status: 400, headers: {'content-type': 'application/json'}} - ); - } - - // update database to start the game - await gameRef.update({ - [`questions.${game.currentQuestionIndex}.playerGuesses.${authUser.uid}`]: answerSelection, - }); - - return NextResponse.json('successfully started game', {status: 200}); -} diff --git a/app-dev/party-game/app/components/border-countdown-timer.tsx b/app-dev/party-game/app/components/border-countdown-timer.tsx index 76b4a5eb..06472b08 100644 --- a/app-dev/party-game/app/components/border-countdown-timer.tsx +++ b/app-dev/party-game/app/components/border-countdown-timer.tsx @@ -16,55 +16,62 @@ 'use client'; -import {Game} from '@/app/types'; +import {Game, gameStates} from '@/app/types'; import {DocumentReference, Timestamp} from 'firebase/firestore'; import {useEffect, useState} from 'react'; -import {timeCalculator} from '../lib/time-calculator'; +import useFirebaseAuthentication from '@/app/hooks/use-firebase-authentication'; +import {nudgeGame} from '@/app/actions/nudge-game'; export default function BorderCountdownTimer({game, children, gameRef}: { game: Game, children: React.ReactNode, gameRef: DocumentReference }) { - const [timeToCountDown, setTimeToCountDown] = useState(game.timePerQuestion); - const [displayTime, setDisplayTime] = useState(game.timePerQuestion); - const [countDirection, setCountDirection] = useState<'down' | 'up'>('down'); + const [timeLeft, setTimeLeft] = useState(game.timePerQuestion); + const displayTime = Math.max(Math.floor(timeLeft), 0); const [localCounter, setLocalCounter] = useState(0); + const gameId = gameRef.id; + const authUser = useFirebaseAuthentication(); + const isShowingCorrectAnswers = game.state === gameStates.SHOWING_CORRECT_ANSWERS; + const timeToCountDown = isShowingCorrectAnswers ? game.timePerAnswer : game.timePerQuestion; useEffect(() => { - const { - timeLeft, - timeToCountDown, - displayTime, - countDirection, - } = timeCalculator({ - currentTimeInMillis: Timestamp.now().toMillis(), - game, - }); + // all times are in seconds unless noted as `InMillis` + const timeElapsedInMillis = Timestamp.now().toMillis() - game.startTime.seconds * 1000; + const timeElapsed = timeElapsedInMillis / 1000; + const timePerQuestionAndAnswer = game.timePerQuestion + game.timePerAnswer; - setTimeToCountDown(timeToCountDown); - setDisplayTime(displayTime); - setCountDirection(countDirection); + if (isShowingCorrectAnswers) { + const timeToStartNextQuestion = timePerQuestionAndAnswer * (game.currentQuestionIndex + 1); + setTimeLeft(timeToStartNextQuestion - timeElapsed); + } else { + const timeToShowCurrentQuestionAnswer = timePerQuestionAndAnswer * (game.currentQuestionIndex) + game.timePerQuestion; + setTimeLeft(timeToShowCurrentQuestionAnswer - timeElapsed); + } + }, [localCounter, game.startTime, game.currentQuestionIndex, game.timePerAnswer, game.timePerQuestion, isShowingCorrectAnswers]); - const nudgeGame = async () => { - await fetch('/api/nudge-game', { - method: 'POST', - body: JSON.stringify({gameId: gameRef.id}), - }).catch((error) => { - console.error({error}); - }); - }; + // game leader nudge + useEffect(() => { + // whenever the game state or question changes + // make a timeout to progress the question + if (authUser.uid === game.leader.uid) { + const timeoutIdTwo = setTimeout(() => nudgeGame({gameId}), timeLeft * 1000); + // clear timeout on re-render to avoid memory leaks + return () => clearTimeout(timeoutIdTwo); + } + }, [timeLeft, game.state, game.currentQuestionIndex, gameId, game.leader.uid, authUser.uid]); - // nudge every three seconds after time has expired - if (timeLeft % 3 < -2) { - nudgeGame(); + // game player nudge + useEffect(() => { + if (timeLeft % 2 < -1) { + nudgeGame({gameId}); } - }, [localCounter, game, gameRef.id]); + }, [timeLeft, gameId]); useEffect(() => { - // save intervalIdOne to clear the interval when the + // save timeoutIdOne to clear the timeout when the // component re-renders const timeoutIdOne = setTimeout(() => { setLocalCounter(localCounter + 1); }, 1000); - // clear interval on re-render to avoid memory leaks + // clear timeout on re-render to avoid memory leaks return () => clearTimeout(timeoutIdOne); }, [localCounter, game.state]); @@ -75,15 +82,16 @@ export default function BorderCountdownTimer({game, children, gameRef}: { game: const timeToCountDivisibleByFour = Math.floor(timeToCountDown / 4) * 4; const animationCompletionPercentage = limitPercents((timeToCountDivisibleByFour - displayTime + 1) / timeToCountDivisibleByFour * 100); - const topBorderPercentage = limitPercents(countDirection === 'down' ? animationCompletionPercentage * 4 : 400 - animationCompletionPercentage * 4); - const rightBorderPercentage = limitPercents(countDirection === 'down' ? animationCompletionPercentage * 4 - 100 : 300 - animationCompletionPercentage * 4); - const bottomBorderPercentage = limitPercents(countDirection === 'down' ? animationCompletionPercentage * 4 - 200 : 200 - animationCompletionPercentage * 4); - const leftBorderPercentage = limitPercents(countDirection === 'down' ? animationCompletionPercentage * 4 - 300 : 100 - animationCompletionPercentage * 4); + const topBorderPercentage = limitPercents(isShowingCorrectAnswers ? 400 - animationCompletionPercentage * 4 : animationCompletionPercentage * 4); + const rightBorderPercentage = limitPercents(isShowingCorrectAnswers ? 300 - animationCompletionPercentage * 4 : animationCompletionPercentage * 4 - 100); + const bottomBorderPercentage = limitPercents(isShowingCorrectAnswers ? 200 - animationCompletionPercentage * 4 : animationCompletionPercentage * 4 - 200); + const leftBorderPercentage = limitPercents(isShowingCorrectAnswers ? 100 - animationCompletionPercentage * 4 : animationCompletionPercentage * 4 - 300); return ( <>
+
diff --git a/app-dev/party-game/app/components/create-game-form.tsx b/app-dev/party-game/app/components/create-game-form.tsx index f4ebbcc2..1492d6a2 100644 --- a/app-dev/party-game/app/components/create-game-form.tsx +++ b/app-dev/party-game/app/components/create-game-form.tsx @@ -20,8 +20,9 @@ import useFirebaseAuthentication from '@/app/hooks/use-firebase-authentication'; import {useRouter} from 'next/navigation'; import {useEffect, useState} from 'react'; import BigColorBorderButton from './big-color-border-button'; -import {unknownParser, unknownValidator} from '@/app/lib/zod-parser'; -import {GameIdObjectSchema, GameSettingsSchema} from '@/app/types'; +import {TimePerAnswerSchema, TimePerQuestionSchema} from '@/app/types'; +import {createGameAction} from '../actions/create-game'; +import {z} from 'zod'; export default function CreateGameForm() { const authUser = useFirebaseAuthentication(); @@ -31,31 +32,42 @@ export default function CreateGameForm() { const [timePerAnswerInputValue, setTimePerAnswerInputValue] = useState(defaultTimePerAnswer.toString()); const timePerQuestion = timePerQuestionInputValue ? parseInt(timePerQuestionInputValue) : -0.5; const timePerAnswer = timePerAnswerInputValue ? parseInt(timePerAnswerInputValue) : -0.5; - const [errorMessage, setErrorMessage] = useState(''); + const [timePerQuestionError, setTimePerQuestionError] = useState(''); + const [timePerAnswerError, setTimePerAnswerError] = useState(''); + const [submissionErrorMessage, setSubmissionErrorMessage] = useState(''); const router = useRouter(); const onCreateGameSubmit = async (event: React.FormEvent) => { event.preventDefault(); const token = await authUser.getIdToken(); try { - const res = await fetch('/api/create-game', { - method: 'POST', - body: JSON.stringify({timePerQuestion, timePerAnswer}), - headers: { - Authorization: token, - }, - }); - const response = await res.json(); - const parsedResponse = unknownParser(response, GameIdObjectSchema); - if (!parsedResponse.gameId) throw new Error('no gameId returned in the response'); - router.push(`/game/${parsedResponse.gameId}`); + const response = await createGameAction({gameSettings: {timePerQuestion, timePerAnswer}, token}); + router.push(`/game/${response.gameId}`); } catch (error) { - setErrorMessage('There was an error handling the request.'); + setSubmissionErrorMessage('There was an error handling the request.'); } }; useEffect(() => { - setErrorMessage(unknownValidator({timePerAnswer, timePerQuestion}, GameSettingsSchema)); - }, [timePerAnswer, timePerQuestion]); + try { + TimePerQuestionSchema.parse(timePerQuestion); + setTimePerQuestionError(''); + } catch (error) { + if (error instanceof z.ZodError) { + setTimePerQuestionError(error.issues[0].message); + } + } + }, [timePerQuestion]); + + useEffect(() => { + try { + TimePerAnswerSchema.parse(timePerAnswer); + setTimePerAnswerError(''); + } catch (error) { + if (error instanceof z.ZodError) { + setTimePerAnswerError(error.issues[0].message); + } + } + }, [timePerAnswer]); return (
@@ -73,6 +85,7 @@ export default function CreateGameForm() { onChange={(event) => setTimePerQuestionInputValue(event.target.value)} placeholder={defaultTimePerQuestion.toString()} /> +

{timePerQuestionError ? timePerQuestionError : <> }

+

{submissionErrorMessage ? submissionErrorMessage : <> }

Create Game diff --git a/app-dev/party-game/app/components/delete-game-button.tsx b/app-dev/party-game/app/components/delete-game-button.tsx index 57134394..686e28cc 100644 --- a/app-dev/party-game/app/components/delete-game-button.tsx +++ b/app-dev/party-game/app/components/delete-game-button.tsx @@ -16,31 +16,20 @@ 'use client'; -import {DocumentReference} from 'firebase/firestore'; import useFirebaseAuthentication from '@/app/hooks/use-firebase-authentication'; -import {useRouter} from 'next/navigation'; +import {deleteGameAction} from '../actions/delete-game'; -export default function DeleteGameButton({gameRef}: { gameRef: DocumentReference }) { +export default function DeleteGameButton({gameId}: { gameId: string }) { const authUser = useFirebaseAuthentication(); - const router = useRouter(); - const onDeleteGameClick = async (gameRef: DocumentReference) => { + const onDeleteGameClick = async () => { const token = await authUser.getIdToken(); - await fetch('/api/delete-game', { - method: 'POST', - body: JSON.stringify({gameId: gameRef.id}), - headers: { - Authorization: token, - }, - }).then(() => router.push('/')) - .catch((error) => { - console.error({error}); - }); + await deleteGameAction({gameId, token}); }; return (
- +
); } diff --git a/app-dev/party-game/app/components/exit-game-button.tsx b/app-dev/party-game/app/components/exit-game-button.tsx index 36d145a1..054b8fa8 100644 --- a/app-dev/party-game/app/components/exit-game-button.tsx +++ b/app-dev/party-game/app/components/exit-game-button.tsx @@ -16,31 +16,23 @@ 'use client'; -import {DocumentReference} from 'firebase/firestore'; import useFirebaseAuthentication from '@/app/hooks/use-firebase-authentication'; import {useRouter} from 'next/navigation'; +import {exitGameAction} from '../actions/exit-game'; -export default function ExitGameButton({gameRef}: { gameRef: DocumentReference }) { +export default function ExitGameButton({gameId}: { gameId: string }) { const authUser = useFirebaseAuthentication(); const router = useRouter(); - const onExitGameClick = async (gameRef: DocumentReference) => { + const onExitGameClick = async () => { const token = await authUser.getIdToken(); - await fetch('/api/exit-game', { - method: 'POST', - body: JSON.stringify({gameId: gameRef.id}), - headers: { - Authorization: token, - }, - }).then(() => router.push('/')) - .catch((error) => { - console.error({error}); - }); + await exitGameAction({gameId, token}); + router.push('/'); }; return (
- +
); } diff --git a/app-dev/party-game/app/components/lobby.tsx b/app-dev/party-game/app/components/lobby.tsx index a77ab6ee..a99cf2ae 100644 --- a/app-dev/party-game/app/components/lobby.tsx +++ b/app-dev/party-game/app/components/lobby.tsx @@ -16,7 +16,6 @@ 'use client'; -import {DocumentReference} from 'firebase/firestore'; import StartGameButton from '@/app/components/start-game-button'; import DeleteGameButton from '@/app/components/delete-game-button'; import PlayerList from './player-list'; @@ -25,10 +24,9 @@ import useFirebaseAuthentication from '../hooks/use-firebase-authentication'; import ShareLinkPanel from './share-link-panel'; import {useState} from 'react'; -export default function Lobby({game, gameRef}: { game: Game; gameRef: DocumentReference }) { +export default function Lobby({game, gameId}: { game: Game; gameId: string }) { const authUser = useFirebaseAuthentication(); - - const [showSharePanel, setShowSharePanel] = useState(false); + const [showSharePanel, setShowSharePanel] = useState(false); return (
@@ -37,18 +35,18 @@ export default function Lobby({game, gameRef}: { game: Game; gameRef: DocumentRe - {showSharePanel && } + {showSharePanel && }
- {authUser.uid === game.leader.uid && } + {authUser.uid === game.leader.uid && }
- +
- {authUser.uid === game.leader.uid && } + {authUser.uid === game.leader.uid && }
); diff --git a/app-dev/party-game/app/components/navbar.tsx b/app-dev/party-game/app/components/navbar.tsx index b246872b..a53801a3 100644 --- a/app-dev/party-game/app/components/navbar.tsx +++ b/app-dev/party-game/app/components/navbar.tsx @@ -40,7 +40,7 @@ export default function Navbar() { return ( - {({open}: { open: Boolean }) => ( + {({open}: { open: boolean }) => ( <>
diff --git a/app-dev/party-game/app/components/question-panel.tsx b/app-dev/party-game/app/components/question-panel.tsx index 83f12633..b20fc846 100644 --- a/app-dev/party-game/app/components/question-panel.tsx +++ b/app-dev/party-game/app/components/question-panel.tsx @@ -25,6 +25,7 @@ import QRCode from 'react-qr-code'; import {useEffect, useState} from 'react'; import Scoreboard from './scoreboard'; import useScoreboard from '../hooks/use-scoreboard'; +import {updateAnswerAction} from '../actions/update-answer'; export default function QuestionPanel({game, gameRef, currentQuestion}: { game: Game, gameRef: DocumentReference, currentQuestion: Question }) { const authUser = useFirebaseAuthentication(); @@ -35,6 +36,7 @@ export default function QuestionPanel({game, gameRef, currentQuestion}: { game: const existingGuesses = currentQuestion?.playerGuesses && currentQuestion.playerGuesses[authUser.uid]; const emptyAnswerSelection = Array(currentQuestion.answers.length).fill(false); const answerSelection = existingGuesses || emptyAnswerSelection; + const gameId = gameRef.id; const totalCorrectAnswerOptions = currentQuestion.answers.reduce((correctAnswerCount, answer) => { return correctAnswerCount + (answer.isCorrect ? 1 : 0); @@ -55,22 +57,14 @@ export default function QuestionPanel({game, gameRef, currentQuestion}: { game: // Typescript does not expect the `with` property on arrays yet // @ts-expect-error - const newAnswerSelection: Boolean[] = startingAnswerSelection.with(answerIndex, !answerSelection[answerIndex]); + const newAnswerSelection: boolean[] = startingAnswerSelection.with(answerIndex, !answerSelection[answerIndex]); const token = await authUser.getIdToken(); - await fetch('/api/update-answer', { - method: 'POST', - body: JSON.stringify({answerSelection: newAnswerSelection, gameId: gameRef.id}), - headers: { - Authorization: token, - }, - }).catch((error) => { - console.error({error}); - }); + await updateAnswerAction({gameId, answerSelection: newAnswerSelection, token}); } }; - const gameShareLink = `${location.protocol}//${location.host}/game/${gameRef.id}`; + const gameShareLink = `${location.protocol}//${location.host}/game/${gameId}`; const isShowingCorrectAnswers = game.state === gameStates.SHOWING_CORRECT_ANSWERS; @@ -149,16 +143,26 @@ export default function QuestionPanel({game, gameRef, currentQuestion}: { game: const colorOrder = ['red', 'blue', 'green', 'yellow']; const color = colorOrder[index]; const isSelected = answerSelection[index]; - - return (
+ const isLeaderReveal = isGameLeader && isShowingCorrectAnswers && answer.isCorrect; + const isChecked = isSelected || isLeaderReveal; + + const histogramBarPercentage = () => { + // don't show bar until after question + if (!isShowingCorrectAnswers) return 0; + // if no guesses were made, show full bar for correct answers + if (totalPlayersWhoMadeAGuess === 0 && isLeaderReveal) return 100; + return guessPercentageForThisAnswer; + }; + + return (
diff --git a/app-dev/party-game/app/components/share-link-panel.tsx b/app-dev/party-game/app/components/share-link-panel.tsx index ce2ae4fa..846b0f65 100644 --- a/app-dev/party-game/app/components/share-link-panel.tsx +++ b/app-dev/party-game/app/components/share-link-panel.tsx @@ -21,7 +21,7 @@ import QRCode from 'react-qr-code'; export default function ShareLinkPanel({gameId}: { gameId: string }) { const gameShareLink = `${location.protocol}//${location.host}/game/${gameId}`; - const [isCopied, setIsCopied] = useState(false); + const [isCopied, setIsCopied] = useState(false); const copyShareLink = () => { navigator.clipboard.writeText(gameShareLink); diff --git a/app-dev/party-game/app/components/start-game-button.tsx b/app-dev/party-game/app/components/start-game-button.tsx index 1e9d41c3..2eba5294 100644 --- a/app-dev/party-game/app/components/start-game-button.tsx +++ b/app-dev/party-game/app/components/start-game-button.tsx @@ -16,29 +16,20 @@ 'use client'; -import {DocumentReference} from 'firebase/firestore'; import useFirebaseAuthentication from '@/app/hooks/use-firebase-authentication'; import './big-color-border-button.css'; import BigColorBorderButton from '@/app/components/big-color-border-button'; +import {startGameAction} from '@/app/actions/start-game'; -export default function StartGameButton({gameRef}: {gameRef: DocumentReference}) { +export default function StartGameButton({gameId}: {gameId: string}) { const authUser = useFirebaseAuthentication(); - const onStartGameClick = async (gameRef: DocumentReference) => { + const onStartGameClick = async () => { const token = await authUser.getIdToken(); - await fetch('/api/start-game', { - method: 'POST', - body: JSON.stringify({gameId: gameRef.id}), - headers: { - Authorization: token, - }, - }) - .catch((error) => { - console.error({error}); - }); + await startGameAction({gameId, token}); }; return ( - onStartGameClick(gameRef)}> + Start Game Now ► ); diff --git a/app-dev/party-game/app/hooks/use-game.ts b/app-dev/party-game/app/hooks/use-game.ts index 0ffd797f..a86efd7f 100644 --- a/app-dev/party-game/app/hooks/use-game.ts +++ b/app-dev/party-game/app/hooks/use-game.ts @@ -20,7 +20,7 @@ import {Game, GameSchema, emptyGame, gameStates} from '@/app/types'; import {doc, onSnapshot} from 'firebase/firestore'; import {usePathname} from 'next/navigation'; import useFirebaseAuthentication from './use-firebase-authentication'; -import {unknownParser} from '../lib/zod-parser'; +import {joinGameAction} from '../actions/join-game'; const useGame = () => { const pathname = usePathname(); @@ -30,35 +30,22 @@ const useGame = () => { const authUser = useFirebaseAuthentication(); useEffect(() => { - const playerIdList = Object.keys(game.players); - const joinGame = async () => { - if (!playerIdList.includes(authUser.uid)) { - const token = await authUser.getIdToken(); - await fetch('/api/join-game', { - method: 'POST', - body: JSON.stringify({gameId}), - headers: { - Authorization: token, - }, - }).catch((error) => { - console.error({error}); - }); - } + const token = await authUser.getIdToken(); + joinGameAction({gameId, token}); }; - if (game.leader.uid && authUser.uid && game.leader.uid !== authUser.uid) { joinGame(); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [authUser.uid, game.leader.uid, gameId]); + }, [authUser.getIdToken, authUser.uid, game.leader.uid]); useEffect(() => { const gameRef = doc(db, 'games', gameId); const unsubscribe = onSnapshot(gameRef, (doc) => { try { - const game = unknownParser(doc.data(), GameSchema); + const game = GameSchema.parse(doc.data()); setGame(game); } catch (error) { console.log(error); diff --git a/app-dev/party-game/app/hooks/use-scoreboard.ts b/app-dev/party-game/app/hooks/use-scoreboard.ts index 32daf7b8..ed66af4e 100644 --- a/app-dev/party-game/app/hooks/use-scoreboard.ts +++ b/app-dev/party-game/app/hooks/use-scoreboard.ts @@ -22,7 +22,7 @@ const useScoreboard = () => { const {game} = useGame(); // create a list of all players - const arraysAreEqual = (a: Boolean[], b: Boolean[]) => { + const arraysAreEqual = (a: boolean[], b: boolean[]) => { return a.every((val, index) => val === b[index]); }; diff --git a/app-dev/party-game/app/lib/name-generator.ts b/app-dev/party-game/app/lib/name-generator.ts index 3b230f5d..5892d94c 100644 --- a/app-dev/party-game/app/lib/name-generator.ts +++ b/app-dev/party-game/app/lib/name-generator.ts @@ -135,8 +135,14 @@ const animalList = [ 'Wombat', ]; -export const generateName = (): string => { - const randomColor = adjectiveList[Math.floor(Math.random() * adjectiveList.length)]; - const randomAnimal = animalList[Math.floor(Math.random() * animalList.length)]; - return `${randomColor} ${randomAnimal}`; +export const generateName = (uid: string) => { + // use a hash so the name is always the same for the same player + // this is helpful to prevent flashes on the screen of one name + // that switch to another name if the user rejoins a game' + const uidArray = uid.split(''); + const adjectiveNumberHash = uidArray.slice(0, 7).reduce((numberTotal, character, index) => numberTotal + character.charCodeAt(0), 0) % adjectiveList.length; + const animalNumberHash = uidArray.slice(7, 14).reduce((numberTotal, character, index) => numberTotal + character.charCodeAt(0), 0) % animalList.length; + const adjective = adjectiveList[adjectiveNumberHash]; + const animal = animalList[animalNumberHash]; + return `${adjective} ${animal}`; }; diff --git a/app-dev/party-game/app/lib/time-calculator.ts b/app-dev/party-game/app/lib/time-calculator.ts deleted file mode 100644 index cf4e59bb..00000000 --- a/app-dev/party-game/app/lib/time-calculator.ts +++ /dev/null @@ -1,59 +0,0 @@ -/** - * Copyright 2023 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import {Game, gameStates} from '@/app/types'; - -export const timeCalculator = ({currentTimeInMillis, game}: { currentTimeInMillis: number, game: Game }) => { - // all times are in seconds unless noted as `InMillis` - const timeElapsedInMillis = currentTimeInMillis - game.startTime.seconds * 1000; - const timeElapsed = timeElapsedInMillis / 1000; - const timePerQuestionAndAnswer = game.timePerQuestion + game.timePerAnswer; - const timeToShowCurrentQuestionAnswer = timePerQuestionAndAnswer * (game.currentQuestionIndex) + game.timePerQuestion; - const timeToStartNextQuestion = timePerQuestionAndAnswer * (game.currentQuestionIndex + 1); - const isTimeToShowAnswer = timeElapsed > timeToShowCurrentQuestionAnswer && game.state === gameStates.AWAITING_PLAYER_ANSWERS; - const isTimeToStartNextQuestion = timeElapsed > timeToStartNextQuestion; - const isOverTime = isTimeToShowAnswer || isTimeToStartNextQuestion; - - let timeLeft; - let countDirection: 'up' | 'down'; - let timeToCountDown; - - if (game.state === gameStates.AWAITING_PLAYER_ANSWERS) { - timeLeft = timeToShowCurrentQuestionAnswer - timeElapsed; - countDirection = 'down'; - timeToCountDown = game.timePerQuestion; - } else { - timeLeft = timeToStartNextQuestion - timeElapsed; - countDirection = 'up'; - timeToCountDown = game.timePerAnswer; - } - - const displayTime = Math.max(Math.floor(timeLeft), 0); - - return { - timeElapsed, - displayTime, - timeLeft, - timeToCountDown, - countDirection, - timePerQuestionAndAnswer, - timeToShowCurrentQuestionAnswer, - timeToStartNextQuestion, - isTimeToShowAnswer, - isTimeToStartNextQuestion, - isOverTime, - }; -}; diff --git a/app-dev/party-game/app/lib/zod-parser.ts b/app-dev/party-game/app/lib/zod-parser.ts deleted file mode 100644 index e27b6494..00000000 --- a/app-dev/party-game/app/lib/zod-parser.ts +++ /dev/null @@ -1,41 +0,0 @@ -/** - * Copyright 2023 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import {z} from 'zod'; - -export const unknownValidator = (body: unknown, Schema: z.ZodType) => { - // Validate request - try { - Schema.parse(body); - } catch (error) { - // return the first error - if (error instanceof z.ZodError) { - return `${error.issues[0].message}`; - } - throw error; - } - - return ''; -}; - -export const unknownParser = (body: unknown, Schema: z.ZodType) => { - // Validate request - const errorMessage = unknownValidator(body, Schema); - if (errorMessage) { - throw new Error(errorMessage); - } - return Schema.parse(body); -}; diff --git a/app-dev/party-game/app/types/index.ts b/app-dev/party-game/app/types/index.ts index a34f8f9f..a5996f5d 100644 --- a/app-dev/party-game/app/types/index.ts +++ b/app-dev/party-game/app/types/index.ts @@ -16,7 +16,7 @@ import {z} from 'zod'; -const GameIdSchema = z.string(); +export const GameIdSchema = z.string(); export const GameIdObjectSchema = z.object({gameId: GameIdSchema}); @@ -27,10 +27,11 @@ export const AnswerSelectionWithGameIdSchema = z.object({ answerSelection: AnswerSelectionSchema, }); -const TimePerQuestionSchema = z.number({invalid_type_error: 'Time per question must be a number'}).int().max(600, 'Time per question must be 600 or less.').min(10, 'Time per question must be at least 10.'); -const TimePerAnswerSchema = z.number({invalid_type_error: 'Time per answer must be a number'}).int().max(600, 'Time per answer must be 600 or less.').min(5, 'Time per answer must be at least 5.'); +export const TimePerQuestionSchema = z.number({invalid_type_error: 'Time per question must be a number'}).int().max(600, 'Time per question must be 600 or less.').min(10, 'Time per question must be at least 10.'); +export const TimePerAnswerSchema = z.number({invalid_type_error: 'Time per answer must be a number'}).int().max(600, 'Time per answer must be 600 or less.').min(5, 'Time per answer must be at least 5.'); export const GameSettingsSchema = z.object({timePerQuestion: TimePerQuestionSchema, timePerAnswer: TimePerAnswerSchema}); +export type GameSettings = z.infer; const AnswerSchema = z.object({ isCorrect: z.boolean(), diff --git a/app-dev/party-game/next.config.js b/app-dev/party-game/next.config.js index 620aeebc..f9389c5e 100644 --- a/app-dev/party-game/next.config.js +++ b/app-dev/party-game/next.config.js @@ -17,6 +17,9 @@ /** @type {import('next').NextConfig} */ const nextConfig = { output: 'standalone', -} + experimental: { + serverActions: true, + }, +}; -module.exports = nextConfig +module.exports = nextConfig;