diff --git a/app-dev/party-game/app/(authenticated-pages)/game-list/page.tsx b/app-dev/party-game/app/(authenticated-pages)/game-list/page.tsx index 0c66319a..27a0ab92 100644 --- a/app-dev/party-game/app/(authenticated-pages)/game-list/page.tsx +++ b/app-dev/party-game/app/(authenticated-pages)/game-list/page.tsx @@ -17,12 +17,10 @@ 'use client'; import GameList from '@/app/components/game-list'; -import Navbar from '@/app/components/navbar'; export default function Home() { return (
-
diff --git a/app-dev/party-game/app/(authenticated-pages)/layout.tsx b/app-dev/party-game/app/(authenticated-pages)/layout.tsx index a70074a4..92aaad53 100644 --- a/app-dev/party-game/app/(authenticated-pages)/layout.tsx +++ b/app-dev/party-game/app/(authenticated-pages)/layout.tsx @@ -20,7 +20,6 @@ import '@/app/globals.css'; import Image from 'next/image'; import useFirebaseAuthentication from '@/app/hooks/use-firebase-authentication'; import BigSignInButton from '@/app/components/big-sign-in-button'; -import Navbar from '@/app/components/navbar'; export default function RootLayout({ children, @@ -38,7 +37,6 @@ export default function RootLayout({ ) : ( <> -
-
Waiting for a game.
diff --git a/app-dev/party-game/app/(unauthenticated-pages)/layout.tsx b/app-dev/party-game/app/(unauthenticated-pages)/layout.tsx index dcdcf765..ebb68618 100644 --- a/app-dev/party-game/app/(unauthenticated-pages)/layout.tsx +++ b/app-dev/party-game/app/(unauthenticated-pages)/layout.tsx @@ -14,7 +14,6 @@ * limitations under the License. */ -import Navbar from '@/app/components/navbar'; import '@/app/globals.css'; export const metadata = { @@ -29,7 +28,6 @@ export default function RootLayout({ }) { return ( <> -
{children}
diff --git a/app-dev/party-game/app/actions/create-game.ts b/app-dev/party-game/app/actions/create-game.ts index a7cde7d1..743e09d0 100644 --- a/app-dev/party-game/app/actions/create-game.ts +++ b/app-dev/party-game/app/actions/create-game.ts @@ -19,7 +19,7 @@ import {gamesRef, questionsRef} from '@/app/lib/firebase-server-initialization'; import {generateName} from '@/app/lib/name-generator'; import {Game, GameSettings, Question, QuestionSchema, Tokens, gameStates} from '@/app/types'; -import {QueryDocumentSnapshot, Timestamp} from 'firebase-admin/firestore'; +import {QueryDocumentSnapshot} from 'firebase-admin/firestore'; import {GameSettingsSchema} from '@/app/types'; import {validateTokens} from '@/app/lib/server-token-validator'; @@ -51,17 +51,16 @@ export async function createGameAction({gameSettings, tokens}: {gameSettings: Ga uid: authUser.uid, }; - const startTime = Timestamp.now(); - - const newGame: Game= { + const newGame: Game = { questions, leader, players: {}, state: gameStates.NOT_STARTED, currentQuestionIndex: 0, - startTime, timePerQuestion: timePerQuestion + 1, // add one for padding between questions timePerAnswer: timePerAnswer + 1, // add one for padding between questions + questionAdvancement: 'AUTOMATIC', + currentStateStartTime: {seconds: 0}, }; const gameRef = await gamesRef.add(newGame); diff --git a/app-dev/party-game/app/actions/delete-game.ts b/app-dev/party-game/app/actions/delete-game.ts index 9b57030f..388b9cc4 100644 --- a/app-dev/party-game/app/actions/delete-game.ts +++ b/app-dev/party-game/app/actions/delete-game.ts @@ -17,7 +17,7 @@ 'use server'; import {gamesRef} from '@/app/lib/firebase-server-initialization'; -import {GameIdSchema, Tokens} from '@/app/types'; +import {GameIdSchema, GameSchema, Tokens} from '@/app/types'; import {validateTokens} from '@/app/lib/server-token-validator'; export async function deleteGameAction({gameId, tokens}: {gameId: string, tokens: Tokens}) { @@ -28,7 +28,7 @@ export async function deleteGameAction({gameId, tokens}: {gameId: string, tokens const gameRef = await gamesRef.doc(gameId); const gameDoc = await gameRef.get(); - const game = gameDoc.data(); + const game = GameSchema.parse(gameDoc.data()); if (game.leader.uid !== authUser.uid) { // Respond with JSON indicating no game was found diff --git a/app-dev/party-game/app/actions/join-game.ts b/app-dev/party-game/app/actions/join-game.ts index b7e630cc..d6b5ad91 100644 --- a/app-dev/party-game/app/actions/join-game.ts +++ b/app-dev/party-game/app/actions/join-game.ts @@ -18,7 +18,7 @@ import {gamesRef} from '@/app/lib/firebase-server-initialization'; import {generateName} from '@/app/lib/name-generator'; -import {GameIdSchema, Tokens} from '@/app/types'; +import {GameIdSchema, GameSchema, Tokens} from '@/app/types'; import {validateTokens} from '@/app/lib/server-token-validator'; export async function joinGameAction({gameId, tokens}: {gameId: string, tokens: Tokens}) { @@ -29,7 +29,7 @@ export async function joinGameAction({gameId, tokens}: {gameId: string, tokens: const gameRef = await gamesRef.doc(gameId); const gameDoc = await gameRef.get(); - const game = gameDoc.data(); + const game = GameSchema.parse(gameDoc.data()); const playerIdList = Object.keys(game.players); if (playerIdList.includes(authUser.uid)) return; if (game.leader.uid === authUser.uid) { diff --git a/app-dev/party-game/app/actions/nudge-game.ts b/app-dev/party-game/app/actions/nudge-game.ts index eb9ed538..6b5963e7 100644 --- a/app-dev/party-game/app/actions/nudge-game.ts +++ b/app-dev/party-game/app/actions/nudge-game.ts @@ -17,44 +17,77 @@ 'use server'; import {gamesRef} from '@/app/lib/firebase-server-initialization'; -import {GameIdSchema, Tokens, gameStates} from '@/app/types'; -import {Timestamp} from 'firebase-admin/firestore'; +import {GameIdSchema, GameSchema, GameStateUpdate, GameStateUpdateSchema, Tokens, gameStates} from '@/app/types'; +import {FieldValue} from 'firebase-admin/firestore'; import {validateTokens} from '@/app/lib/server-token-validator'; -export async function nudgeGameAction({gameId, tokens}: {gameId: string, tokens: Tokens}) { - await validateTokens(tokens); +export async function nudgeGameAction({gameId, desiredState, tokens}: { gameId: string, desiredState: GameStateUpdate, tokens: Tokens }) { + const authUser = await validateTokens(tokens); // Validate request // Will throw an error if not a string GameIdSchema.parse(gameId); + GameStateUpdateSchema.parse(desiredState); const gameRef = await gamesRef.doc(gameId); const gameDoc = await gameRef.get(); - const game = gameDoc.data(); + const game = GameSchema.parse(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; + if (game.leader.uid !== authUser.uid) { + // Respond with JSON indicating no game was found + throw new Error('Only the leader of this game may advance this game.'); + } 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; + const nextQuestionIndex = game.currentQuestionIndex + 1; + + const {NOT_STARTED, AWAITING_PLAYER_ANSWERS, GAME_OVER, SHOWING_CORRECT_ANSWERS} = gameStates; - await gameRef.update({ - state: correctState, - currentQuestionIndex: correctQuestionIndex, - }); + // if the game is already in the desired state, no further action required + if (game.state === desiredState.state && game.currentQuestionIndex === desiredState.currentQuestionIndex) { + throw new Error('Desired state is same as current state. No changes to be made.'); + } + + switch (game.state) { + case GAME_OVER: + throw new Error('The game is over. No game progression is allowed.'); + case NOT_STARTED: + if (desiredState.state === AWAITING_PLAYER_ANSWERS && desiredState.currentQuestionIndex === 0) { + await gameRef.update({ + state: AWAITING_PLAYER_ANSWERS, + currentQuestionIndex: 0, + currentStateStartTime: FieldValue.serverTimestamp(), + }); + return; + } + throw new Error('The only allowed game progression when NOT_STARTED is to AWAITING_PLAYER_ANSWERS'); + case SHOWING_CORRECT_ANSWERS: + if (desiredState.currentQuestionIndex === nextQuestionIndex) { + if (game.currentQuestionIndex === finalQuestionIndex) { + await gameRef.update({ + state: GAME_OVER, + currentStateStartTime: FieldValue.serverTimestamp(), + }); + return; + } + await gameRef.update({ + state: AWAITING_PLAYER_ANSWERS, + currentQuestionIndex: nextQuestionIndex, + currentStateStartTime: FieldValue.serverTimestamp(), + }); + return; + } + throw new Error('The only allowed game progression when SHOWING_CORRECT_ANSWERS is to AWAITING_PLAYER_ANSWERS on the next question'); + case AWAITING_PLAYER_ANSWERS: + if (desiredState.state === SHOWING_CORRECT_ANSWERS && desiredState.currentQuestionIndex === game.currentQuestionIndex) { + await gameRef.update({ + state: SHOWING_CORRECT_ANSWERS, + currentStateStartTime: FieldValue.serverTimestamp(), + }); + return; + } + throw new Error('The only allowed game progression when AWAITING_PLAYER_ANSWERS is to SHOWING_CORRECT_ANSWERS'); + } } diff --git a/app-dev/party-game/app/actions/start-game.ts b/app-dev/party-game/app/actions/start-game.ts deleted file mode 100644 index 6ba10b39..00000000 --- a/app-dev/party-game/app/actions/start-game.ts +++ /dev/null @@ -1,44 +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. - */ - -'use server'; - -import {gamesRef} from '@/app/lib/firebase-server-initialization'; -import {GameIdSchema, Tokens, gameStates} from '@/app/types'; -import {FieldValue} from 'firebase-admin/firestore'; -import {validateTokens} from '@/app/lib/server-token-validator'; - -export async function startGameAction({gameId, tokens}: {gameId: string, tokens: Tokens}) { - const authUser = await validateTokens(tokens); - - // 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 index 4af2c2ed..e6695036 100644 --- a/app-dev/party-game/app/actions/update-answer.ts +++ b/app-dev/party-game/app/actions/update-answer.ts @@ -17,7 +17,7 @@ 'use server'; import {gamesRef} from '@/app/lib/firebase-server-initialization'; -import {GameIdSchema, Tokens, gameStates} from '@/app/types'; +import {GameIdSchema, GameSchema, Tokens, gameStates} from '@/app/types'; import {z} from 'zod'; import {validateTokens} from '@/app/lib/server-token-validator'; @@ -29,7 +29,7 @@ export async function updateAnswerAction({gameId, answerSelection, tokens}: {gam const gameRef = await gamesRef.doc(gameId); const gameDoc = await gameRef.get(); - const game = gameDoc.data(); + const game = GameSchema.parse(gameDoc.data()); if (game.state !== gameStates.AWAITING_PLAYER_ANSWERS) { return new Error(`Answering is not allowed during ${game.state}.`); 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 d6343501..6d16039d 100644 --- a/app-dev/party-game/app/components/border-countdown-timer.tsx +++ b/app-dev/party-game/app/components/border-countdown-timer.tsx @@ -16,9 +16,9 @@ 'use client'; -import {Game, gameStates} from '@/app/types'; +import {Game, GameStateUpdate, gameStates, questionAdvancements} from '@/app/types'; import {DocumentReference, Timestamp} from 'firebase/firestore'; -import {useEffect, useState} from 'react'; +import {useEffect, useState, useCallback} from 'react'; import useFirebaseAuthentication from '@/app/hooks/use-firebase-authentication'; import {nudgeGameAction} from '@/app/actions/nudge-game'; import {getTokens} from '@/app/lib/client-token-generator'; @@ -32,43 +32,45 @@ export default function BorderCountdownTimer({game, children, gameRef}: { game: const isShowingCorrectAnswers = game.state === gameStates.SHOWING_CORRECT_ANSWERS; const timeToCountDown = isShowingCorrectAnswers ? game.timePerAnswer : game.timePerQuestion; - const nudgeGame = async ({gameId}: {gameId: string}) => { - const tokens = await getTokens(); - nudgeGameAction({gameId, tokens}); - }; + const nudgeGame = useCallback(async ({gameId, desiredState}: { gameId: string, desiredState: GameStateUpdate }) => { + if (game.state === desiredState.state && game.currentQuestionIndex === desiredState.currentQuestionIndex) return; + // all times are in seconds unless noted as `InMillis` + const timeElapsedInMillis = Timestamp.now().toMillis() - game.currentStateStartTime.seconds * 1000; + const timeElapsed = timeElapsedInMillis / 1000; + if (authUser.uid === game.leader.uid && timeElapsed > 2) { + const tokens = await getTokens(); + nudgeGameAction({gameId, desiredState, tokens}); + } + }, [authUser.uid, game.currentQuestionIndex, game.currentStateStartTime.seconds, game.leader.uid, game.state]); useEffect(() => { // all times are in seconds unless noted as `InMillis` - const timeElapsedInMillis = Timestamp.now().toMillis() - game.startTime.seconds * 1000; + const timeElapsedInMillis = Timestamp.now().toMillis() - game.currentStateStartTime.seconds * 1000; const timeElapsed = timeElapsedInMillis / 1000; - const timePerQuestionAndAnswer = game.timePerQuestion + game.timePerAnswer; + const isShowingCorrectAnswers = game.state === gameStates.SHOWING_CORRECT_ANSWERS; if (isShowingCorrectAnswers) { - const timeToStartNextQuestion = timePerQuestionAndAnswer * (game.currentQuestionIndex + 1); - setTimeLeft(timeToStartNextQuestion - timeElapsed); + const timeLeft = game.timePerAnswer - timeElapsed; + if (timeLeft < 0 && game.questionAdvancement === questionAdvancements.AUTOMATIC) { + const desiredState = { + state: gameStates.AWAITING_PLAYER_ANSWERS, + currentQuestionIndex: game.currentQuestionIndex + 1, + }; + nudgeGame({gameId, desiredState}); + } + setTimeLeft(timeLeft); } else { - const timeToShowCurrentQuestionAnswer = timePerQuestionAndAnswer * (game.currentQuestionIndex) + game.timePerQuestion; - setTimeLeft(timeToShowCurrentQuestionAnswer - timeElapsed); - } - }, [localCounter, game.startTime, game.currentQuestionIndex, game.timePerAnswer, game.timePerQuestion, isShowingCorrectAnswers]); - - // 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]); - - // game player nudge - useEffect(() => { - if (timeLeft % 2 < -1) { - nudgeGame({gameId}); + const timeLeft = game.timePerQuestion - timeElapsed; + if (timeLeft < 0) { + const desiredState = { + state: gameStates.SHOWING_CORRECT_ANSWERS, + currentQuestionIndex: game.currentQuestionIndex, + }; + nudgeGame({gameId, desiredState}); + } + setTimeLeft(timeLeft); } - }, [timeLeft, gameId]); + }, [localCounter, game.currentStateStartTime, game.currentQuestionIndex, game.timePerAnswer, game.timePerQuestion, isShowingCorrectAnswers, game.state, game.leader.uid, game.questionAdvancement, authUser.uid, gameId, nudgeGame]); useEffect(() => { // save timeoutIdOne to clear the timeout when the @@ -103,9 +105,26 @@ export default function BorderCountdownTimer({game, children, gameRef}: { game:
-
+
{displayTime < 10 && '0'} {displayTime} + {authUser.uid === game.leader.uid && (<> +
 
+
+ +
+ )}
{children}
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 e1883619..6092d597 100644 --- a/app-dev/party-game/app/components/start-game-button.tsx +++ b/app-dev/party-game/app/components/start-game-button.tsx @@ -18,13 +18,18 @@ import './big-color-border-button.css'; import BigColorBorderButton from '@/app/components/big-color-border-button'; -import {startGameAction} from '@/app/actions/start-game'; +import {nudgeGameAction} from '@/app/actions/nudge-game'; import {getTokens} from '@/app/lib/client-token-generator'; +import {gameStates} from '../types'; export default function StartGameButton({gameId}: {gameId: string}) { const onStartGameClick = async () => { const tokens = await getTokens(); - await startGameAction({gameId, tokens}); + const desiredState = { + state: gameStates.AWAITING_PLAYER_ANSWERS, + currentQuestionIndex: 0, + }; + nudgeGameAction({gameId, desiredState, tokens}); }; return ( diff --git a/app-dev/party-game/app/lib/firebase-client-initialization.ts b/app-dev/party-game/app/lib/firebase-client-initialization.ts index a6ec48ce..d08f91e4 100644 --- a/app-dev/party-game/app/lib/firebase-client-initialization.ts +++ b/app-dev/party-game/app/lib/firebase-client-initialization.ts @@ -31,6 +31,8 @@ export let appCheck: AppCheck; if (typeof window !== 'undefined') { // Create a ReCaptchaEnterpriseProvider instance using your reCAPTCHA Enterprise // site key and pass it to initializeAppCheck(). + // @ts-expect-error + self.FIREBASE_APPCHECK_DEBUG_TOKEN = true; appCheck = initializeAppCheck(app, { provider: new ReCaptchaEnterpriseProvider('6Lc3JP8nAAAAAPrX4s-HwUT8L-k_aMtbKGhJEq_0'), isTokenAutoRefreshEnabled: true, // Set to true to allow auto-refresh. diff --git a/app-dev/party-game/app/not-found.tsx b/app-dev/party-game/app/not-found.tsx index 194b9c0c..c0747e25 100644 --- a/app-dev/party-game/app/not-found.tsx +++ b/app-dev/party-game/app/not-found.tsx @@ -17,7 +17,6 @@ 'use client'; import {usePathname} from 'next/navigation'; -import Navbar from '@/app/components/navbar'; import ReturnToHomepagePanel from '@/app/components/return-to-homepage-panel'; export default function Home() { @@ -25,7 +24,6 @@ export default function Home() { return (
-
diff --git a/app-dev/party-game/app/types/index.ts b/app-dev/party-game/app/types/index.ts index 1cd02893..4cb48332 100644 --- a/app-dev/party-game/app/types/index.ts +++ b/app-dev/party-game/app/types/index.ts @@ -30,7 +30,10 @@ export const AnswerSelectionWithGameIdSchema = z.object({ 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 const GameSettingsSchema = z.object({ + timePerQuestion: TimePerQuestionSchema, + timePerAnswer: TimePerAnswerSchema, +}); export type GameSettings = z.infer; const AnswerSchema = z.object({ @@ -51,6 +54,30 @@ const gameStatesOptions = ['NOT_STARTED', 'SHOWING_CORRECT_ANSWERS', 'AWAITING_P const GameStateEnum = z.enum(gameStatesOptions); export const gameStates = GameStateEnum.Values; +export const GameStateUpdateSchema = z.object({ + state: GameStateEnum, + currentQuestionIndex: z.number().int().nonnegative(), +}); +export type GameStateUpdate = z.infer; + +export const questionAdvancementOptionDetails = [ + { + type: 'MANUAL', + description: 'Manually advance to the next question.', + shortName: 'Manual', + automaticallyAdvanceToNextQuestion: false, + }, + { + type: 'AUTOMATIC', + description: 'Automatically advance question on a timer.', + shortName: 'Automatic', + automaticallyAdvanceToNextQuestion: true, + }, +] as const; +const questionAdvancementOptions = ['MANUAL', 'AUTOMATIC'] as const; +const questionAdvancementEnum = z.enum(questionAdvancementOptions); +export const questionAdvancements = questionAdvancementEnum.Values; + export const LeaderSchema = z.object({ uid: z.string(), displayName: z.string(), @@ -66,7 +93,8 @@ export const GameSchema = z.object({ players: z.record(z.string(), z.string()), state: GameStateEnum, currentQuestionIndex: z.number().int().nonnegative(), - startTime: z.object({seconds: z.number()}), + currentStateStartTime: z.object({seconds: z.number()}), + questionAdvancement: questionAdvancementEnum, timePerQuestion: TimePerQuestionSchema, timePerAnswer: TimePerAnswerSchema, }); @@ -76,7 +104,8 @@ export const emptyGame = GameSchema.parse({ players: {}, state: 'NOT_STARTED', currentQuestionIndex: 0, - startTime: {seconds: 0}, + questionAdvancement: 'AUTOMATIC', + currentStateStartTime: {seconds: 0}, timePerQuestion: 60, timePerAnswer: 20, });