From 46ff32a986bda747693fd554841bbebda665825c Mon Sep 17 00:00:00 2001 From: Mawoka Date: Mon, 27 Mar 2023 21:04:08 +0200 Subject: [PATCH] :sparkles: Implemented ClassQuizController-logic --- classquiz/__init__.py | 4 + classquiz/db/models.py | 3 +- classquiz/routers/box_controller/__init__.py | 11 ++ classquiz/routers/box_controller/embedded.py | 153 ++++++++++++++++++ .../routers/box_controller/embedded_ws.md | 25 +++ classquiz/routers/box_controller/web.py | 52 ++++++ classquiz/routers/quiz.py | 27 +++- classquiz/socket_server/__init__.py | 75 +++++---- frontend/src/lib/dashboard/start_game.svelte | 52 +++++- .../lib/play/admin/game_not_started.svelte | 104 ++++++++++++ frontend/src/routes/admin/+page.svelte | 61 +------ frontend/src/routes/controller/+page.svelte | 13 ++ live_redis_data.md | 6 + 13 files changed, 488 insertions(+), 98 deletions(-) create mode 100644 classquiz/routers/box_controller/__init__.py create mode 100644 classquiz/routers/box_controller/embedded.py create mode 100644 classquiz/routers/box_controller/embedded_ws.md create mode 100644 classquiz/routers/box_controller/web.py create mode 100644 frontend/src/lib/play/admin/game_not_started.svelte create mode 100644 frontend/src/routes/controller/+page.svelte diff --git a/classquiz/__init__.py b/classquiz/__init__.py index ecda284..ffb464a 100644 --- a/classquiz/__init__.py +++ b/classquiz/__init__.py @@ -32,6 +32,7 @@ from classquiz.routers import ( avatar, results, admin, + box_controller, ) from classquiz.socket_server import sio from classquiz.helpers import meilisearch_init, telemetry_ping, bg_tasks @@ -85,6 +86,9 @@ async def auth_middleware_wrapper(request: Request, call_next): return await rememberme_middleware(request, call_next) +app.include_router( + box_controller.router, tags=["boxcontroller"], prefix="/api/v1/box-controller", include_in_schema=True +) app.include_router(results.router, tags=["results"], prefix="/api/v1/results", include_in_schema=True) app.include_router(remote.router, tags=["remote"], prefix="/api/v1/remote", include_in_schema=True) app.include_router(login.router, tags=["auth"], prefix="/api/v1/login", include_in_schema=True) diff --git a/classquiz/db/models.py b/classquiz/db/models.py index 0935360..fffd3b3 100644 --- a/classquiz/db/models.py +++ b/classquiz/db/models.py @@ -241,11 +241,12 @@ class PlayGame(BaseModel): background_color: str | None background_image: str | None custom_field: str | None + question_show: bool = False class GamePlayer(BaseModel): username: str - sid: str + sid: str | None class GameAnswer2(BaseModel): diff --git a/classquiz/routers/box_controller/__init__.py b/classquiz/routers/box_controller/__init__.py new file mode 100644 index 0000000..ad72997 --- /dev/null +++ b/classquiz/routers/box_controller/__init__.py @@ -0,0 +1,11 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at https://mozilla.org/MPL/2.0/. + +from fastapi import APIRouter +from classquiz.routers.box_controller import web, embedded + +router = APIRouter() + +router.include_router(web.router, prefix="/web") +router.include_router(embedded.router, prefix="/embedded") diff --git a/classquiz/routers/box_controller/embedded.py b/classquiz/routers/box_controller/embedded.py new file mode 100644 index 0000000..288d6c3 --- /dev/null +++ b/classquiz/routers/box_controller/embedded.py @@ -0,0 +1,153 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at https://mozilla.org/MPL/2.0/. +import enum +import os +import typing +from datetime import datetime + +from fastapi import APIRouter, HTTPException, WebSocket, WebSocketDisconnect, status +from pydantic import BaseModel, ValidationError +from classquiz.config import redis +from classquiz.db.models import PlayGame, QuizQuestionType, AnswerData, GamePlayer +from classquiz.socket_server import sio, calculate_score, set_answer + +router = APIRouter() + + +class JoinGameInput(BaseModel): + code: str + name: str + + +class JoinGameResponse(BaseModel): + id: str + + +@router.post("/join") +async def join_game(data: JoinGameInput): + game_pin = await redis.get(f"game:cqc:code:{data.code}") + if game_pin is None: + raise HTTPException(status_code=404, detail="Game not found") + game = await redis.get(f"game:{game_pin}") + game = PlayGame.parse_raw(game) + # Check if game is already running + if game.started: + raise HTTPException(status_code=400, detail="Game started already") + # check if username already exists + if await redis.get(f"game_session:{game_pin}:players:{data.name}") is not None: + raise HTTPException(status_code=409, detail="Username already exists") + + player_id = os.urandom(5).hex() + await redis.set(f"game:cqc:player:{player_id}", data.name) + return JoinGameResponse(id=f"{player_id}:{game_pin}") + + +class SubmitAnswerInput(BaseModel): + answer: int + + +async def submit_answer_fn(data_answer: int, game_pin: str, player_id: str, now: datetime): + redis_res_game = await redis.get(f"game:{game_pin}") + username = await redis.get(f"game:cqc:player:{player_id}") + if redis_res_game is None or username is None: + raise HTTPException(status_code=404, detail="id not existent") + game = PlayGame.parse_raw(redis_res_game) + if not game.question_show: + return + question = game.questions[game.current_question] + question_time = datetime.fromisoformat(await redis.get(f"game:{game_pin}:current_time")) + try: + selected_answer = question.answers[data_answer].answer + except KeyError: + return + answer_right = False + if question.type == QuizQuestionType.ABCD: + for answer in question.answers: + if answer.answer == selected_answer and answer.right: + answer_right = True + break + elif question.type == QuizQuestionType.VOTING: + answer_right = False + else: + return + diff = (question_time - now).total_seconds() * 1000 + score = 0 + if answer_right: + score = calculate_score(abs(diff), int(float(question.time))) + await redis.hincrby(f"game_session:{game_pin}:player_scores", username, score) + answer_data = AnswerData( + username=username, + answer=selected_answer, + right=answer_right, + time_taken=abs(diff), + score=score, + ) + answers = await redis.get(f"game_session:{game_pin}:{game.current_question}") + answers = await set_answer(answers, game_pin=game_pin, data=answer_data, q_index=game.current_question) + player_count = await redis.scard(f"game_session:{game_pin}:players") + print(player_count, answers) + if answers is not None and len(answers.__root__) == player_count: + await sio.emit("everyone_answered", {}) + + +class WebSocketTypes(enum.Enum): + ButtonPress = "bp" + Error = "e" + + +class WebSocketRequest(BaseModel): + type: WebSocketTypes + data: typing.Any + + +wss_clients = {} + +button_to_index_map = {"b": 0, "g": 1, "y": 2, "r": 3} + + +@router.websocket("/socket/{id}") +async def websocket_endpoint(ws: WebSocket, game_id: str, id: str): + try: + if id in wss_clients.keys(): + await ws.close(code=status.WS_1001_GOING_AWAY) + print("Client {} already exists.".format(id)) + return + + await ws.accept() + wss_clients[id] = ws + + player_id, game_pin = game_id.split(":") + if player_id is None or game_pin is None: + await ws.send_text(WebSocketRequest(type=WebSocketTypes.Error, data="BadId").json()) + await ws.close(code=status.WS_1003_UNSUPPORTED_DATA) + username = await redis.get(f"game:cqc:player:{player_id}") + await sio.emit( + "player_joined", + {"username": username, "sid": None}, + room=f"admin:{game_pin}", + ) + await redis.sadd(f"game_session:{game_pin}:players", GamePlayer(username=username, sid=None).json()) + + while True: + raw_data = await ws.receive_text() + try: + data = WebSocketRequest.parse_raw(raw_data) + except ValidationError: + await ws.send_text(WebSocketRequest(type=WebSocketTypes.Error, data="ValidationError").json()) + continue + + if data.type == WebSocketTypes.ButtonPress: + now = datetime.now() + try: + answer_index = button_to_index_map[data.data.lower()] + except (KeyError, AttributeError): + await ws.send_text(WebSocketRequest(type=WebSocketTypes.Error, data="InvalidButton").json()) + continue + + await submit_answer_fn(answer_index, game_pin, player_id, now) + print("Data from client {}: {}".format(id, data)) + + except WebSocketDisconnect as ex: + print("Client {} is disconnected: {}".format(id, ex)) + wss_clients.pop(id, None) diff --git a/classquiz/routers/box_controller/embedded_ws.md b/classquiz/routers/box_controller/embedded_ws.md new file mode 100644 index 0000000..07ffa25 --- /dev/null +++ b/classquiz/routers/box_controller/embedded_ws.md @@ -0,0 +1,25 @@ +## Basic Request + +Every request is a json-object: `{"type": "SOME_TYPE", "data": "ANY_DATA"}` + +### Types + +#### e (Error) + +- [ ] Client +- [x] Server + +Pretty self-explanatory. + +Value is a CamelCase errorcode. +Codes: + +- `ValidationError` +- `BadId` + +#### bp (ButtonPress) + +- [x] Client +- [ ] Server + +Sends a button-press to the server, where `data` is either `b`, `g`, `y` or `r`. Capital letters indicate a long-press. diff --git a/classquiz/routers/box_controller/web.py b/classquiz/routers/box_controller/web.py new file mode 100644 index 0000000..72afce5 --- /dev/null +++ b/classquiz/routers/box_controller/web.py @@ -0,0 +1,52 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at https://mozilla.org/MPL/2.0/. +import random + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel + +from classquiz.auth import get_current_user +from classquiz.db.models import User, PlayGame +from classquiz.config import redis + +router = APIRouter() + + +def generate_code() -> str: + specified_length = 6 + buttons = [ + "B", + "b", + "G", + "g", + "Y", + "y", + "R", + "r", + ] # Capital stands for long press, lowercase letter for short press + resulting_code = "" + for i in range(specified_length): + resulting_code += random.choice(buttons) + return resulting_code + + +class ActivateCqbForCurrentQuizInput(BaseModel): + game_pin: int + + +class ActivateCqbForCurrentQuizResponse(BaseModel): + code: str + + +@router.post("/activate-for-quiz", response_model=ActivateCqbForCurrentQuizResponse) +async def activate_cqc_for_current_quiz(data: ActivateCqbForCurrentQuizInput, user: User = Depends(get_current_user)): + redis_res = await redis.get(f"game:{data.game_pin}") + if redis_res is None: + raise HTTPException(status_code=404, detail="Game not found") + game_data = PlayGame.parse_raw(redis_res) + if game_data.user_id != user.id: + raise HTTPException(status_code=401, detail="The quiz wasn't started by you") + code = generate_code() + await redis.set(f"game:cqc:code:{code}", game_data.game_pin, ex=3600) + return ActivateCqbForCurrentQuizResponse(code=code) diff --git a/classquiz/routers/quiz.py b/classquiz/routers/quiz.py index 58ef9f9..2ab1c06 100644 --- a/classquiz/routers/quiz.py +++ b/classquiz/routers/quiz.py @@ -3,6 +3,7 @@ # file, You can obtain one at https://mozilla.org/MPL/2.0/. import json +import random import re import uuid from datetime import datetime @@ -98,12 +99,31 @@ async def get_public_quiz(quiz_id: str): return PublicQuizResponse(**quiz.dict()) +def generate_code() -> str: + specified_length = 6 + buttons = [ + "B", + "b", + "G", + "g", + "Y", + "y", + "R", + "r", + ] # Capital stands for long press, lowercase letter for short press + resulting_code = "" + for i in range(specified_length): + resulting_code += random.choice(buttons) + return resulting_code + + @router.post("/start/{quiz_id}") async def start_quiz( quiz_id: str, game_mode: str, captcha_enabled: bool = True, custom_field: str | None = None, + cqcs_enabled: bool = False, user: User = Depends(get_current_user), ): try: @@ -138,14 +158,19 @@ async def start_quiz( custom_field=custom_field, background_image=quiz.background_image, ) + code = None + if cqcs_enabled: + code = generate_code() + await redis.set(f"game:cqc:code:{code}", game_pin, ex=3600) await redis.set(f"game:{str(game.game_pin)}", game.json(), ex=18000) await redis.set(f"game_pin:{user.id}:{quiz_id}", game_pin, ex=18000) + await redis.set( f"game_in_lobby:{user.id.hex}", GameInLobby(game_id=game.game_id, game_pin=str(game_pin), quiz_title=quiz.title).json(), ex=900, ) - return {**quiz.dict(exclude={"id"}), **game.dict(exclude={"questions"})} + return {**quiz.dict(exclude={"id"}), **game.dict(exclude={"questions"}), "cqc_code": code} class CheckIfCaptchaEnabledResponse(BaseModel): diff --git a/classquiz/socket_server/__init__.py b/classquiz/socket_server/__init__.py index 967b136..5fd331f 100644 --- a/classquiz/socket_server/__init__.py +++ b/classquiz/socket_server/__init__.py @@ -51,6 +51,26 @@ async def generate_final_results(game_data: PlayGame, game_pin: str) -> dict: return results +def calculate_score(z: float, t: int) -> int: + t = t * 1000 + res = (t - z) / t + return int(res * 1000) + + +async def set_answer(answers, game_pin: str, q_index: int, data: AnswerData) -> AnswerDataList: + if answers is None: + answers = AnswerDataList(__root__=[data]) + else: + answers = AnswerDataList.parse_raw(answers) + answers.__root__.append(data) + await redis.set( + f"game_session:{game_pin}:{q_index}", + answers.json(), + ex=7200, + ) + return answers + + class _JoinGameData(BaseModel): username: str game_pin: str @@ -205,6 +225,9 @@ async def get_question_results(sid: str, data: dict): session = await sio.get_session(sid) if session["admin"]: redis_res = await redis.get(f"game_session:{session['game_pin']}:{data['question_number']}") + game_data = PlayGame.parse_raw(await redis.get(f"game:{session['game_pin']}")) + game_data.question_show = False + await redis.set(f"game:{session['game_pin']}", game_data.json()) game_pin = session["game_pin"] await sio.emit("question_results", redis_res, room=game_pin) @@ -244,6 +267,7 @@ async def set_question_number(sid, data: str): game_pin = session["game_pin"] game_data = PlayGame.parse_raw(await redis.get(f"game:{session['game_pin']}")) game_data.current_question = int(float(data)) + game_data.question_show = True await redis.set(f"game:{session['game_pin']}", game_data.json(), ex=7200) await redis.set(f"game:{session['game_pin']}:current_time", datetime.now().isoformat(), ex=7200) temp_return = game_data.dict(include={"questions"})["questions"][int(float(data))] @@ -323,7 +347,6 @@ async def submit_answer(sid: str, data: dict): data.answer = ", ".join(answer_order) if correct_answers == data.dict()["complex_answer"]: answer_right = True - # TODO Set data.answer to a real value for export elif game_data.questions[int(float(data.question_index))].type == QuizQuestionType.TEXT: answer_right = False @@ -344,10 +367,6 @@ async def submit_answer(sid: str, data: dict): diff = (time_q_started - now).total_seconds() * 1000 # - timedelta(milliseconds=latency) # print(abs(diff) - latency, latency, abs(diff)) - def calculate_score(z: float, t: int) -> int: - t = t * 1000 - res = (t - z) / t - return int(res * 1000) score = 0 if answer_right: @@ -355,39 +374,16 @@ async def submit_answer(sid: str, data: dict): abs(diff) - latency, int(float(game_data.questions[int(float(data.question_index))].time)) ) await redis.hincrby(f"game_session:{session['game_pin']}:player_scores", session["username"], score) - if answers is None: - await redis.set( - f"game_session:{session['game_pin']}:{data.question_index}", - AnswerDataList( - __root__=[ - AnswerData( - username=session["username"], - answer=data.answer, - right=answer_right, - time_taken=abs(diff) - latency, - score=score, - ) - ] - ).json(), - ex=7200, - ) - else: - answers = AnswerDataList.parse_raw(answers) - answers.__root__.append( - AnswerData( - username=session["username"], - answer=data.answer, - right=answer_right, - time_taken=abs(diff) - latency, - score=score, - ) - ) - await redis.set( - f"game_session:{session['game_pin']}:{data.question_index}", - answers.json(), - ex=7200, - ) - answers = AnswerDataList.parse_raw(await redis.get(f"game_session:{session['game_pin']}:{data.question_index}")) + answer_data = AnswerData( + username=session["username"], + answer=data.answer, + right=answer_right, + time_taken=abs(diff) - latency, + score=score, + ) + answers = await set_answer( + answers, game_pin=session["game_pin"], data=answer_data, q_index=int(float(data.question_index)) + ) player_count = await redis.scard(f"game_session:{session['game_pin']}:players") if len(answers.__root__) == player_count: # await sio.emit( @@ -395,6 +391,9 @@ async def submit_answer(sid: str, data: dict): # await redis.get(f"game_session:{session['game_pin']}:{data.question_index}"), # room=session["game_pin"], # ) + game_data = PlayGame.parse_raw(await redis.get(f"game:{session['game_pin']}")) + game_data.question_show = False + await redis.set(f"game:{session['game_pin']}", game_data.json()) await sio.emit("everyone_answered", {}) diff --git a/frontend/src/lib/dashboard/start_game.svelte b/frontend/src/lib/dashboard/start_game.svelte index 7b464e5..1249dfc 100644 --- a/frontend/src/lib/dashboard/start_game.svelte +++ b/frontend/src/lib/dashboard/start_game.svelte @@ -10,12 +10,21 @@ import { fade } from 'svelte/transition'; import Spinner from '$lib/Spinner.svelte'; import { onMount } from 'svelte'; + import { createTippy } from 'svelte-tippy'; export let quiz_id; let captcha_selected = false; let selected_game_mode = 'kahoot'; let loading = false; let custom_field = ''; + let cqcs_enabled = false; + + const tippy = createTippy({ + arrow: true, + animation: 'perspective-subtle', + placement: 'top-start', + allowHTML: true + }); onMount(() => { const ls_data = localStorage.getItem('custom_field'); @@ -26,16 +35,17 @@ let res; loading = true; localStorage.setItem('custom_field', custom_field); + const cqcs_enabled_parsed = cqcs_enabled ? 'True' : 'False'; if (captcha_enabled && captcha_selected) { res = await fetch( - `/api/v1/quiz/start/${id}?captcha_enabled=True&game_mode=${selected_game_mode}&custom_field=${custom_field}`, + `/api/v1/quiz/start/${id}?captcha_enabled=True&game_mode=${selected_game_mode}&custom_field=${custom_field}&cqcs_enabled=${cqcs_enabled_parsed}`, { method: 'POST' } ); } else { res = await fetch( - `/api/v1/quiz/start/${id}?captcha_enabled=False&game_mode=${selected_game_mode}&custom_field=${custom_field}`, + `/api/v1/quiz/start/${id}?captcha_enabled=False&game_mode=${selected_game_mode}&custom_field=${custom_field}&cqcs_enabled=${cqcs_enabled_parsed}`, { method: 'POST' } @@ -54,7 +64,9 @@ const data = await res.json(); // eslint-disable-next-line no-undef plausible('Started Game', { props: { quiz_id: id, game_id: data.game_id } }); - window.location.assign(`/admin?token=${data.game_id}&pin=${data.game_pin}&connect=1`); + window.location.assign( + `/admin?token=${data.game_id}&pin=${data.game_pin}&connect=1&cqc_code=${data.cqc_code}` + ); } }; @@ -89,7 +101,7 @@ {#if captcha_selected} -
+

If enabled, Google's ReCaptcha will load in the browser of players. Only enable if you really need it, since you need the consent of EVERY player to load @@ -128,7 +140,7 @@

-
+
+
+ +
+
+ {/if} + diff --git a/frontend/src/routes/admin/+page.svelte b/frontend/src/routes/admin/+page.svelte index e0f9694..4c096f2 100644 --- a/frontend/src/routes/admin/+page.svelte +++ b/frontend/src/routes/admin/+page.svelte @@ -11,11 +11,12 @@ import { navbarVisible } from '$lib/stores'; import type { PlayerAnswer, Player } from '$lib/admin.ts'; import SomeAdminScreen from '$lib/admin.svelte'; - import AudioPlayer from '$lib/play/audio_player.svelte'; + import GameNotStarted from '$lib/play/admin/game_not_started.svelte'; import { browser } from '$app/environment'; import { onMount } from 'svelte'; import FinalResults from '$lib/play/admin/final_results.svelte'; import GrayButton from '$lib/components/buttons/gray.svelte'; + import { page } from '$app/stores'; navbarVisible.set(false); @@ -41,7 +42,6 @@ let success = false; let dataexport_download_a; let warnToLeave = true; - let play_music = false; const connect = async () => { socket.emit('register_as_admin', { @@ -127,17 +127,6 @@ window.matchMedia('(prefers-color-scheme: dark)').matches); } - const kick_player = (username: string) => { - socket.emit('kick_player', { username: username }); - for (let i = 0; i < players.length; i++) { - console.log(players[i].username, username); - if (players[i].username === username) { - players.splice(i, 1); - break; - } - } - players = players; - }; let bg_color; let bg_image; let results_saved = false; @@ -201,46 +190,12 @@

{errorMessage}

{/if} {:else if !game_started} -
- -
- QR code to join the game -
-

{$t('words.pin')}: {quiz_data.game_pin}

-
-
    - {#if players.length > 0} - {#each players as player} -
  • - { - kick_player(player.username); - }}>{player.username} - -
  • - {/each} - {/if} -
-
- {#if players.length > 0} -
- -
- {/if} -
+ {:else} + +
+

ClassQuizController

+
+

Play a quiz with a physical controller, not on a touchscreen!

+
+

More infos will follow soon

+
diff --git a/live_redis_data.md b/live_redis_data.md index 1e1e3ad..a4c4c88 100644 --- a/live_redis_data.md +++ b/live_redis_data.md @@ -122,3 +122,9 @@ Holds the custom-field data, but is only set if the custom-field is enabled. data: `{PLAYER_NAME} = {CUSTOM_FIELD_VALUE}` + +## game:cqb:code:{cqc_code} [string] + +{cqc_code} is the code used to join with a **C**lass**Q**uiz**C**ontroller + +Only holds the game-pin