From 70b0da40dc258b59bc36a3dacb10f46c8790cb51 Mon Sep 17 00:00:00 2001 From: Paul Z Date: Thu, 2 Nov 2023 22:46:38 +0100 Subject: [PATCH] generic question type support --- src/error.rs | 7 +- src/game.rs | 155 ++++++++++++++-------------- src/main.rs | 85 +++++++++++---- src/question.rs | 1 + src/question/single_choice.rs | 131 +++++++++++++++++++++++ templates/index.stpl | 11 +- templates/play.stpl | 36 +------ templates/single_choice/player.stpl | 10 ++ templates/single_choice/result.stpl | 20 ++++ templates/single_choice/viewer.stpl | 54 ++++++++++ templates/view.stpl | 120 ++------------------- testquiz.toml | 6 +- 12 files changed, 385 insertions(+), 251 deletions(-) create mode 100644 src/question.rs create mode 100644 src/question/single_choice.rs create mode 100644 templates/single_choice/player.stpl create mode 100644 templates/single_choice/result.stpl create mode 100644 templates/single_choice/viewer.stpl diff --git a/src/error.rs b/src/error.rs index 6d2698b..a7bf6fc 100644 --- a/src/error.rs +++ b/src/error.rs @@ -24,7 +24,7 @@ pub enum Error { GameAlreadyStarted, #[error("field already submitted")] - FieldAlreadySubmitted, + QuestionAlreadySubmitted, #[error("player not found")] PlayerNotFound, @@ -43,6 +43,9 @@ pub enum Error { #[error("forbidden")] Forbidden, + + #[error("invalid input")] + InvalidInput, } impl IntoResponse for Error { @@ -52,7 +55,7 @@ impl IntoResponse for Error { Self::Toml(_) => (StatusCode::OK, "invalid toml syntax").into_response(), Self::QuizFileNotFound => (StatusCode::OK, "quizfile not found").into_response(), Self::PlayerNotFound => (StatusCode::BAD_REQUEST, "player not found").into_response(), - Self::FieldAlreadySubmitted => { + Self::QuestionAlreadySubmitted => { (StatusCode::BAD_REQUEST, "field already submitted").into_response() } Self::GameAlreadyStarted => { diff --git a/src/game.rs b/src/game.rs index af76a8e..a5f4df1 100644 --- a/src/game.rs +++ b/src/game.rs @@ -1,4 +1,8 @@ -use std::{collections::HashMap, sync::Arc, time::Duration}; +use std::{ + collections::{HashMap, HashSet}, + sync::Arc, + time::Duration, +}; use qrcode::{render::svg, QrCode}; use sailfish::TemplateOnce; @@ -8,17 +12,27 @@ use tokio::{ }; use crate::{ - error::Error, HandlerResult, PlayTemplate, PlayerState, Quiz, ViewTemplate, ViewerState, + error::Error, HandlerResult, PlayTemplate, PlayerState, Question, Quiz, ViewTemplate, + ViewerState, }; +#[derive(Clone, Debug)] +pub enum GameState { + NotStarted, + Answering(u32), + Result(u32), + Completed, +} + pub struct Game { pub id: String, pub owner: String, pub state: Arc>, pub quiz: Quiz, - pub players: HashMap, + pub players: HashSet, pub on_state_update: broadcast::Sender<()>, pub on_submission: broadcast::Sender<()>, + pub questions: Vec>, } impl Game { @@ -26,28 +40,18 @@ impl Game { Self { id, owner, + questions: quiz + .questions + .iter() + .map(|x| x.clone().into()) + .collect::>(), quiz, state: Arc::new(RwLock::new(GameState::NotStarted)), - players: HashMap::new(), + players: HashSet::new(), on_state_update: broadcast::channel(16).0, on_submission: broadcast::channel(16).0, } } - pub fn submissions(&self, field: u32) -> Vec { - let field = field as usize; - - self.players.iter().fold( - vec![0; self.quiz.fields[field].answers.len()], - |mut pacc, p| { - if p.1.submissions.len() > field { - if let Some(Some(submission)) = p.1.submissions.get(field) { - pacc[*submission as usize] += 1; - } - } - pacc - }, - ) - } pub async fn next(&mut self) { let mut state = self.state.write().await; @@ -55,7 +59,7 @@ impl Game { *state = match *state { GameState::NotStarted => GameState::Answering(0), GameState::Answering(field) => GameState::Result(field), - GameState::Result(field) if (field as usize) + 1 < self.quiz.fields.len() => { + GameState::Result(field) if (field as usize) + 1 < self.quiz.questions.len() => { GameState::Answering(field + 1) } GameState::Result(_) => GameState::Completed, @@ -84,35 +88,39 @@ impl Game { } pub async fn player_view(&self, player_id: &str, htmx: bool) -> HandlerResult { - let player = self.players.get(player_id).ok_or(Error::PlayerNotFound)?; - - let player_position: u32 = player.submissions.len() as u32; + if !self.players.contains(player_id) { + return Err(Error::PlayerNotFound); + } let state = self.state.read().await; let player_state = match *state { GameState::NotStarted => PlayerState::NotStarted, - GameState::Answering(i) if player_position <= i => { - PlayerState::Answering((i, &self.quiz.fields[i as usize])) + GameState::Answering(i) + if !self.questions[i as usize].has_answered(player_id).await? => + { + let inner_body = self.questions[i as usize] + .render_player(player_id, false) + .await?; + PlayerState::Answering { inner_body } } GameState::Answering(i) => PlayerState::Waiting(i), - GameState::Result(i) => PlayerState::Result(( - &self.quiz.fields[i as usize], - player.submissions.get(i as usize).and_then(|x| *x), - )), - GameState::Completed => PlayerState::Completed( - player - .submissions - .iter() - .enumerate() - .fold(0, |acc, (i, e)| { - match *e == Some(self.quiz.fields[i].correct) { - true => acc + 1, - false => acc, - } - }) as f32 - / (self.quiz.fields.len() as f32), - ), + GameState::Result(i) => { + let inner_body = self.questions[i as usize] + .render_player(player_id, true) + .await?; + PlayerState::Result { inner_body } + } + GameState::Completed => { + let mut correct = 0; + for question in self.questions.iter() { + if question.answered_correctly(player_id).await? { + correct += 1; + } + } + + PlayerState::Completed(correct as f32 / (self.quiz.questions.len() as f32)) + } }; Ok(PlayTemplate { @@ -124,32 +132,28 @@ impl Game { .render_once()?) } - pub async fn handle_submission(&mut self, player_id: &str, value: u32) -> HandlerResult<()> { - let player = self - .players - .get_mut(player_id) - .ok_or(Error::PlayerNotFound)?; - - let player_position = player.submissions.len(); + pub async fn handle_answer( + &mut self, + player_id: &str, + values: &HashMap, + ) -> HandlerResult<()> { + if !self.players.contains(player_id) { + return Err(Error::PlayerNotFound); + } let state = self.state.read().await; - match *state { - GameState::Answering(i) if (player_position as u32) <= i => { - if (value as usize) < self.quiz.fields[i as usize].answers.len() { - player - .submissions - .append(&mut vec![None; (i as usize) - player_position]); - player.submissions.push(Some(value)); - self.on_submission.send(()); + if let GameState::Answering(i) = *state { + self.questions[i as usize] + .handle_answer(player_id, values) + .await?; - if self.submissions(i).iter().sum::() as usize == self.players.len() { - drop(state); - self.next().await; - } - } + self.on_submission.send(()); + + if self.questions[i as usize].answer_count().await? as usize == self.players.len() { + drop(state); + self.next().await; } - _ => {} } Ok(()) @@ -166,10 +170,17 @@ impl Game { ViewerState::NotStarted((self.players.len() as u32, img, url)) } GameState::Answering(i) => { - ViewerState::Answering((i, &self.quiz.fields[i as usize], self.submissions(i))) + let inner_body = self.questions[i as usize].render_viewer(false).await?; + + ViewerState::Answering { inner_body } } GameState::Result(i) => { - ViewerState::Result((i, &self.quiz.fields[i as usize], self.submissions(i))) + let inner_body = self.questions[i as usize].render_viewer(true).await?; + + ViewerState::Result { + last_question: (i + 1) as usize == self.quiz.questions.len(), + inner_body, + } } GameState::Completed => ViewerState::Completed, @@ -178,22 +189,8 @@ impl Game { Ok(ViewTemplate { htmx, id: &self.id, - quiz: &self.quiz, state: viewer_state, } .render_once()?) } } - -#[derive(Debug, Default)] -pub struct Player { - pub submissions: Vec>, -} - -#[derive(Clone, Debug)] -pub enum GameState { - NotStarted, - Answering(u32), - Result(u32), - Completed, -} diff --git a/src/main.rs b/src/main.rs index d2b6422..7c53085 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,6 +7,7 @@ use std::{ }; use axum::{ + async_trait, extract::{FromRef, Multipart, Path, Query, State}, http::Uri, response::{ @@ -19,8 +20,9 @@ use axum::{ use axum_htmx::{HxRedirect, HxRequest}; use axum_oidc::oidc::{self, EmptyAdditionalClaims, OidcApplication, OidcExtractor}; use futures_util::Stream; -use game::{Game, Player}; +use game::Game; use garbage_collector::{start_gc, GarbageCollectorItem}; +use question::single_choice::SingleChoiceQuestion; use rand::{distributions, Rng}; use sailfish::TemplateOnce; use serde::{Deserialize, Serialize}; @@ -37,6 +39,8 @@ mod game; mod garbage_collector; mod stream; +mod question; + #[derive(Clone)] pub struct AppState { games: Arc>>, @@ -94,8 +98,8 @@ pub async fn main() { let app = Router::new() .route("/", get(handle_index).post(handle_create)) - .route("/:id", get(handle_play).post(handle_play_submission)) - .route("/:id/events", get(sse_play)) + .route("/:id", get(handle_player).post(handle_player_answer)) + .route("/:id/events", get(sse_player)) .route("/:id/view", get(handle_view).post(handle_view_next)) .route("/:id/view/events", get(sse_view)) .nest_service("/static", ServeDir::new("static")) @@ -129,7 +133,7 @@ pub async fn handle_create( let game_id: String = rand::thread_rng() .sample_iter(distributions::Alphanumeric) - .take(16) + .take(8) .map(char::from) .collect(); @@ -216,7 +220,7 @@ pub struct PlayerQuery { player: Option, } -pub async fn handle_play( +pub async fn handle_player( Query(query): Query, Path(id): Path, State(state): State, @@ -233,8 +237,7 @@ pub async fn handle_play( .take(32) .map(char::from) .collect(); - game.players - .insert(player_id.to_string(), Player::default()); + game.players.insert(player_id.to_string()); game.on_submission.send(()); Ok(Redirect::temporary(&format!( @@ -247,11 +250,12 @@ pub async fn handle_play( #[derive(Deserialize)] pub struct SubmissionPayload { - selected: u32, player_id: String, + #[serde(flatten)] + values: HashMap, } -pub async fn handle_play_submission( +pub async fn handle_player_answer( Path(id): Path, State(state): State, Form(form): Form, @@ -259,8 +263,7 @@ pub async fn handle_play_submission( let mut games = state.games.write().await; let game = games.get_mut(&id).ok_or(Error::NotFound)?; - game.handle_submission(&form.player_id, form.selected) - .await?; + game.handle_answer(&form.player_id, &form.values).await?; Ok(Html(game.player_view(&form.player_id, true).await?)) } @@ -270,7 +273,7 @@ pub struct SsePlayerQuery { player: String, } -pub async fn sse_play( +pub async fn sse_player( Query(query): Query, Path(id): Path, State(state): State, @@ -294,15 +297,15 @@ struct PlayTemplate<'a> { htmx: bool, id: &'a str, player_id: &'a str, - state: PlayerState<'a>, + state: PlayerState, } #[derive(Clone)] -pub enum PlayerState<'a> { +pub enum PlayerState { NotStarted, - Answering((u32, &'a SingleChoice)), + Answering { inner_body: String }, Waiting(u32), - Result((&'a SingleChoice, Option)), + Result { inner_body: String }, Completed(f32), } @@ -311,22 +314,33 @@ pub enum PlayerState<'a> { struct ViewTemplate<'a> { htmx: bool, id: &'a str, - quiz: &'a Quiz, - state: ViewerState<'a>, + state: ViewerState, } #[derive(Clone)] -pub enum ViewerState<'a> { +pub enum ViewerState { NotStarted((u32, String, String)), - Answering((u32, &'a SingleChoice, Vec)), - Result((u32, &'a SingleChoice, Vec)), + Answering { + inner_body: String, + }, + Result { + last_question: bool, + inner_body: String, + }, Completed, } #[derive(Debug, Serialize, Deserialize, Clone)] pub struct Quiz { pub wait_for: u64, - pub fields: Vec, + pub questions: Vec, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(tag = "type")] +pub enum QuizQuestion { + #[serde(rename = "single_choice")] + SingleChoice(SingleChoice), } #[derive(Debug, Serialize, Deserialize, Clone)] @@ -335,3 +349,30 @@ pub struct SingleChoice { answers: Vec, correct: u32, } + +#[async_trait] +pub trait Question: Send + Sync { + async fn render_player(&self, player_id: &str, show_result: bool) -> Result; + + async fn handle_answer( + &mut self, + player_id: &str, + values: &HashMap, + ) -> Result<(), Error>; + + async fn has_answered(&self, player_id: &str) -> Result; + + async fn answered_correctly(&self, player_id: &str) -> Result; + + async fn answer_count(&self) -> Result; + + async fn render_viewer(&self, show_result: bool) -> Result; +} + +impl From for Box { + fn from(value: QuizQuestion) -> Self { + match value { + QuizQuestion::SingleChoice(x) => Box::new(SingleChoiceQuestion::new(x)) as _, + } + } +} diff --git a/src/question.rs b/src/question.rs new file mode 100644 index 0000000..983b6d2 --- /dev/null +++ b/src/question.rs @@ -0,0 +1 @@ +pub mod single_choice; diff --git a/src/question/single_choice.rs b/src/question/single_choice.rs new file mode 100644 index 0000000..f60c945 --- /dev/null +++ b/src/question/single_choice.rs @@ -0,0 +1,131 @@ +use std::collections::HashMap; + +use axum::async_trait; +use sailfish::TemplateOnce; + +use crate::{error::Error, Question, SingleChoice}; + +pub struct SingleChoiceQuestion { + inner: SingleChoice, + submissions: HashMap, +} + +impl SingleChoiceQuestion { + pub fn new(single_choice: SingleChoice) -> Self { + Self { + inner: single_choice, + submissions: HashMap::new(), + } + } +} + +#[async_trait] +impl Question for SingleChoiceQuestion { + async fn render_player(&self, player_id: &str, show_result: bool) -> Result { + if show_result { + let player_sub_index = self.submissions.get(player_id); + + let player_sub_value = + player_sub_index.map(|x| self.inner.answers[*x as usize].as_str()); + + Ok(ResultTemplate { + is_correct: Some(&self.inner.correct) == player_sub_index, + correct_answer: &self.inner.answers[self.inner.correct as usize], + player_answer: player_sub_value, + } + .render_once()?) + } else { + Ok(PlayerTemplate { + name: &self.inner.name, + player_id, + answers: &self.inner.answers, + } + .render_once()?) + } + } + + async fn handle_answer( + &mut self, + player_id: &str, + values: &HashMap, + ) -> Result<(), Error> { + let value = values + .get("value") + .ok_or(Error::InvalidInput)? + .parse::() + .map_err(|_| Error::InvalidInput)?; + + if value as usize >= self.inner.answers.len() { + return Err(Error::InvalidInput); + } + + if self.submissions.contains_key(player_id) { + return Err(Error::QuestionAlreadySubmitted); + } + + self.submissions.insert(player_id.to_string(), value); + + Ok(()) + } + + async fn has_answered(&self, player_id: &str) -> Result { + Ok(self.submissions.get(player_id).is_some()) + } + + async fn answered_correctly(&self, player_id: &str) -> Result { + Ok(self + .submissions + .get(player_id) + .map(|x| *x == self.inner.correct) + .unwrap_or_default()) + } + + async fn answer_count(&self) -> Result { + Ok(self.submissions.len() as u32) + } + + async fn render_viewer(&self, show_result: bool) -> Result { + Ok(ViewerTemplate { + show_result, + name: &self.inner.name, + total_submissions: self.submissions.iter().fold(0, |acc, (_, v)| acc + v), + submissions: &self.submissions.iter().fold( + vec![0; self.inner.answers.len()], + |mut acc, (_, v)| { + acc[*v as usize] += 1; + acc + }, + ), + correct_answer: &self.inner.answers[self.inner.correct as usize], + answers: &self.inner.answers, + } + .render_once()?) + } +} + +#[derive(TemplateOnce)] +#[template(path = "single_choice/player.stpl")] +struct PlayerTemplate<'a> { + name: &'a str, + player_id: &'a str, + answers: &'a [String], +} + +#[derive(TemplateOnce)] +#[template(path = "single_choice/result.stpl")] +struct ResultTemplate<'a> { + is_correct: bool, + correct_answer: &'a str, + player_answer: Option<&'a str>, +} + +#[derive(TemplateOnce)] +#[template(path = "single_choice/viewer.stpl")] +struct ViewerTemplate<'a> { + show_result: bool, + name: &'a str, + total_submissions: u32, + submissions: &'a [u32], + correct_answer: &'a str, + answers: &'a [String], +} diff --git a/templates/index.stpl b/templates/index.stpl index 7bff517..6ec9f32 100644 --- a/templates/index.stpl +++ b/templates/index.stpl @@ -14,15 +14,18 @@ # number of seconds to wait before showing results
wait_for = 15

- [[fields]]
- # name of the field
+ [[questions]]
+ # type of the question (currently only single_choice)
+ type = "single_choice"
+ # name of the question
name = "Who is there?"
# array of possible answers
answers = [ "A", "B", "C", "D"]
# index (starting at 0) of the correct answer
correct = 0

- [[fields]]
+ [[questions]]
+ type = "single_choice"
name = "What is there?"
answers = [ "A", "B", "C", "D"]
correct = 0
@@ -33,7 +36,7 @@
- +
diff --git a/templates/play.stpl b/templates/play.stpl index 2d7fcd6..f350ef3 100644 --- a/templates/play.stpl +++ b/templates/play.stpl @@ -14,42 +14,14 @@
The Quiz hasn't started yet. Please wait for the first question.
- <% } else if let PlayerState::Answering((field_id, field)) = state { %> -
-

<%= field.name %>

- <% for (index, answer) in field.answers.iter().enumerate() { %> -
- - - -
- <% } %> -
+ <% } else if let PlayerState::Answering{ inner_body } = state { %> + <%- inner_body %> <% } else if let PlayerState::Waiting(_) = state{ %>
You answered the current question. Please wait for the results.
- <% } else if let PlayerState::Result((field, selected)) = state{ %> - <% if Some(field.correct) == selected { %> -
-
-

Correct

-

Your answer is correct. The correct answer is <%= field.answers[field.correct as usize] %>.

-
- <% } else { %> -
-
-

Wrong

-

- Your answer is incorrect. The correct answer is <%= field.answers[field.correct as usize] %>. - <% if let Some(s) = selected { %> - You answered <%=field.answers[s as usize]%>. - <% } else { %> - You didn't answer the question. - <% } %> -

-
- <% } %> + <% } else if let PlayerState::Result{ inner_body } = state{ %> + <%- inner_body %> <% } else if let PlayerState::Completed(correct) = state { %>
The Quiz finished. You can close this tab now. You answered <%= correct*100.0 %>% correctly. diff --git a/templates/single_choice/player.stpl b/templates/single_choice/player.stpl new file mode 100644 index 0000000..063751a --- /dev/null +++ b/templates/single_choice/player.stpl @@ -0,0 +1,10 @@ +
+

<%= name %>

+ <% for (index, answer) in answers.iter().enumerate() { %> +
+ + + +
+ <% } %> +
diff --git a/templates/single_choice/result.stpl b/templates/single_choice/result.stpl new file mode 100644 index 0000000..55509bc --- /dev/null +++ b/templates/single_choice/result.stpl @@ -0,0 +1,20 @@ +<% if is_correct { %> +
+
+

Correct

+

Your answer is correct. The correct answer is <%= correct_answer %>.

+
+<% } else { %> +
+
+

Wrong

+

+ Your answer is incorrect. The correct answer is <%= correct_answer %>. + <% if let Some(player_answer) = player_answer { %> + You answered <%= player_answer %>. + <% } else { %> + You didn't answer the question. + <% } %> +

+
+<% } %> diff --git a/templates/single_choice/viewer.stpl b/templates/single_choice/viewer.stpl new file mode 100644 index 0000000..92ab402 --- /dev/null +++ b/templates/single_choice/viewer.stpl @@ -0,0 +1,54 @@ +

<%= name %>

+Total Participants: <%= total_submissions %> +<% if show_result { %> +
+ The correct answer is: <%= correct_answer %> +<% } %> + + + + + diff --git a/templates/view.stpl b/templates/view.stpl index e80996e..b654e81 100644 --- a/templates/view.stpl +++ b/templates/view.stpl @@ -14,121 +14,21 @@ <% } %>

zettoIT ARS

-<% if let ViewerState::Answering((current_field, field, data)) = state { %> -

<%= field.name%>

- Total Participants: <%= data.iter().fold(0, |acc, e| acc +e) %> +<% if let ViewerState::Answering{ inner_body } = state { %> + <%- inner_body %> - + -<% if (current_field+1) as usize >= quiz.fields.len() { %> - -<% } else { %> - -<% } %> +<% } else if let ViewerState::Result{ last_question, inner_body} = state { %> - + <%- inner_body %> -<% } else if let ViewerState::Result((current_field, field, data)) = state { %> -

<%= field.name%>

- Total Participants: <%= data.iter().fold(0, |acc, e| acc +e) %> -
- The correct answer is: <%=field.answers[field.correct as usize]%> + <% if last_question { %> + + <% } else { %> + + <% } %> - - -<% if (current_field+1) as usize >= quiz.fields.len() { %> - -<% } else { %> - -<% } %> - - <% } else if let ViewerState::NotStarted((player, qrcode, url)) = state { %>
<%- qrcode %>
diff --git a/testquiz.toml b/testquiz.toml index 113bc02..a336775 100644 --- a/testquiz.toml +++ b/testquiz.toml @@ -1,11 +1,13 @@ wait_for = 15 -[[fields]] +[[questions]] +type = "single_choice" name = "Who is there?" answers = [ "A", "B", "C", "D"] correct = 0 -[[fields]] +[[questions]] +type = "single_choice" name = "What is there?" answers = [ "A", "B", "C", "D"] correct = 0