generic question type support
This commit is contained in:
parent
4bf6afaf4e
commit
70b0da40dc
12 changed files with 385 additions and 251 deletions
|
@ -24,7 +24,7 @@ pub enum Error {
|
||||||
GameAlreadyStarted,
|
GameAlreadyStarted,
|
||||||
|
|
||||||
#[error("field already submitted")]
|
#[error("field already submitted")]
|
||||||
FieldAlreadySubmitted,
|
QuestionAlreadySubmitted,
|
||||||
|
|
||||||
#[error("player not found")]
|
#[error("player not found")]
|
||||||
PlayerNotFound,
|
PlayerNotFound,
|
||||||
|
@ -43,6 +43,9 @@ pub enum Error {
|
||||||
|
|
||||||
#[error("forbidden")]
|
#[error("forbidden")]
|
||||||
Forbidden,
|
Forbidden,
|
||||||
|
|
||||||
|
#[error("invalid input")]
|
||||||
|
InvalidInput,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl IntoResponse for Error {
|
impl IntoResponse for Error {
|
||||||
|
@ -52,7 +55,7 @@ impl IntoResponse for Error {
|
||||||
Self::Toml(_) => (StatusCode::OK, "invalid toml syntax").into_response(),
|
Self::Toml(_) => (StatusCode::OK, "invalid toml syntax").into_response(),
|
||||||
Self::QuizFileNotFound => (StatusCode::OK, "quizfile not found").into_response(),
|
Self::QuizFileNotFound => (StatusCode::OK, "quizfile not found").into_response(),
|
||||||
Self::PlayerNotFound => (StatusCode::BAD_REQUEST, "player 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()
|
(StatusCode::BAD_REQUEST, "field already submitted").into_response()
|
||||||
}
|
}
|
||||||
Self::GameAlreadyStarted => {
|
Self::GameAlreadyStarted => {
|
||||||
|
|
147
src/game.rs
147
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 qrcode::{render::svg, QrCode};
|
||||||
use sailfish::TemplateOnce;
|
use sailfish::TemplateOnce;
|
||||||
|
@ -8,17 +12,27 @@ use tokio::{
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::{
|
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 struct Game {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub owner: String,
|
pub owner: String,
|
||||||
pub state: Arc<RwLock<GameState>>,
|
pub state: Arc<RwLock<GameState>>,
|
||||||
pub quiz: Quiz,
|
pub quiz: Quiz,
|
||||||
pub players: HashMap<String, Player>,
|
pub players: HashSet<String>,
|
||||||
pub on_state_update: broadcast::Sender<()>,
|
pub on_state_update: broadcast::Sender<()>,
|
||||||
pub on_submission: broadcast::Sender<()>,
|
pub on_submission: broadcast::Sender<()>,
|
||||||
|
pub questions: Vec<Box<dyn Question>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Game {
|
impl Game {
|
||||||
|
@ -26,28 +40,18 @@ impl Game {
|
||||||
Self {
|
Self {
|
||||||
id,
|
id,
|
||||||
owner,
|
owner,
|
||||||
|
questions: quiz
|
||||||
|
.questions
|
||||||
|
.iter()
|
||||||
|
.map(|x| x.clone().into())
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
quiz,
|
quiz,
|
||||||
state: Arc::new(RwLock::new(GameState::NotStarted)),
|
state: Arc::new(RwLock::new(GameState::NotStarted)),
|
||||||
players: HashMap::new(),
|
players: HashSet::new(),
|
||||||
on_state_update: broadcast::channel(16).0,
|
on_state_update: broadcast::channel(16).0,
|
||||||
on_submission: broadcast::channel(16).0,
|
on_submission: broadcast::channel(16).0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pub fn submissions(&self, field: u32) -> Vec<u32> {
|
|
||||||
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) {
|
pub async fn next(&mut self) {
|
||||||
let mut state = self.state.write().await;
|
let mut state = self.state.write().await;
|
||||||
|
@ -55,7 +59,7 @@ impl Game {
|
||||||
*state = match *state {
|
*state = match *state {
|
||||||
GameState::NotStarted => GameState::Answering(0),
|
GameState::NotStarted => GameState::Answering(0),
|
||||||
GameState::Answering(field) => GameState::Result(field),
|
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::Answering(field + 1)
|
||||||
}
|
}
|
||||||
GameState::Result(_) => GameState::Completed,
|
GameState::Result(_) => GameState::Completed,
|
||||||
|
@ -84,35 +88,39 @@ impl Game {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn player_view(&self, player_id: &str, htmx: bool) -> HandlerResult<String> {
|
pub async fn player_view(&self, player_id: &str, htmx: bool) -> HandlerResult<String> {
|
||||||
let player = self.players.get(player_id).ok_or(Error::PlayerNotFound)?;
|
if !self.players.contains(player_id) {
|
||||||
|
return Err(Error::PlayerNotFound);
|
||||||
let player_position: u32 = player.submissions.len() as u32;
|
}
|
||||||
|
|
||||||
let state = self.state.read().await;
|
let state = self.state.read().await;
|
||||||
|
|
||||||
let player_state = match *state {
|
let player_state = match *state {
|
||||||
GameState::NotStarted => PlayerState::NotStarted,
|
GameState::NotStarted => PlayerState::NotStarted,
|
||||||
GameState::Answering(i) if player_position <= i => {
|
GameState::Answering(i)
|
||||||
PlayerState::Answering((i, &self.quiz.fields[i as usize]))
|
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::Answering(i) => PlayerState::Waiting(i),
|
||||||
GameState::Result(i) => PlayerState::Result((
|
GameState::Result(i) => {
|
||||||
&self.quiz.fields[i as usize],
|
let inner_body = self.questions[i as usize]
|
||||||
player.submissions.get(i as usize).and_then(|x| *x),
|
.render_player(player_id, true)
|
||||||
)),
|
.await?;
|
||||||
GameState::Completed => PlayerState::Completed(
|
PlayerState::Result { inner_body }
|
||||||
player
|
}
|
||||||
.submissions
|
GameState::Completed => {
|
||||||
.iter()
|
let mut correct = 0;
|
||||||
.enumerate()
|
for question in self.questions.iter() {
|
||||||
.fold(0, |acc, (i, e)| {
|
if question.answered_correctly(player_id).await? {
|
||||||
match *e == Some(self.quiz.fields[i].correct) {
|
correct += 1;
|
||||||
true => acc + 1,
|
}
|
||||||
false => acc,
|
}
|
||||||
|
|
||||||
|
PlayerState::Completed(correct as f32 / (self.quiz.questions.len() as f32))
|
||||||
}
|
}
|
||||||
}) as f32
|
|
||||||
/ (self.quiz.fields.len() as f32),
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(PlayTemplate {
|
Ok(PlayTemplate {
|
||||||
|
@ -124,33 +132,29 @@ impl Game {
|
||||||
.render_once()?)
|
.render_once()?)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn handle_submission(&mut self, player_id: &str, value: u32) -> HandlerResult<()> {
|
pub async fn handle_answer(
|
||||||
let player = self
|
&mut self,
|
||||||
.players
|
player_id: &str,
|
||||||
.get_mut(player_id)
|
values: &HashMap<String, String>,
|
||||||
.ok_or(Error::PlayerNotFound)?;
|
) -> HandlerResult<()> {
|
||||||
|
if !self.players.contains(player_id) {
|
||||||
let player_position = player.submissions.len();
|
return Err(Error::PlayerNotFound);
|
||||||
|
}
|
||||||
|
|
||||||
let state = self.state.read().await;
|
let state = self.state.read().await;
|
||||||
|
|
||||||
match *state {
|
if let GameState::Answering(i) = *state {
|
||||||
GameState::Answering(i) if (player_position as u32) <= i => {
|
self.questions[i as usize]
|
||||||
if (value as usize) < self.quiz.fields[i as usize].answers.len() {
|
.handle_answer(player_id, values)
|
||||||
player
|
.await?;
|
||||||
.submissions
|
|
||||||
.append(&mut vec![None; (i as usize) - player_position]);
|
|
||||||
player.submissions.push(Some(value));
|
|
||||||
self.on_submission.send(());
|
self.on_submission.send(());
|
||||||
|
|
||||||
if self.submissions(i).iter().sum::<u32>() as usize == self.players.len() {
|
if self.questions[i as usize].answer_count().await? as usize == self.players.len() {
|
||||||
drop(state);
|
drop(state);
|
||||||
self.next().await;
|
self.next().await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
@ -166,10 +170,17 @@ impl Game {
|
||||||
ViewerState::NotStarted((self.players.len() as u32, img, url))
|
ViewerState::NotStarted((self.players.len() as u32, img, url))
|
||||||
}
|
}
|
||||||
GameState::Answering(i) => {
|
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) => {
|
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,
|
GameState::Completed => ViewerState::Completed,
|
||||||
|
@ -178,22 +189,8 @@ impl Game {
|
||||||
Ok(ViewTemplate {
|
Ok(ViewTemplate {
|
||||||
htmx,
|
htmx,
|
||||||
id: &self.id,
|
id: &self.id,
|
||||||
quiz: &self.quiz,
|
|
||||||
state: viewer_state,
|
state: viewer_state,
|
||||||
}
|
}
|
||||||
.render_once()?)
|
.render_once()?)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Default)]
|
|
||||||
pub struct Player {
|
|
||||||
pub submissions: Vec<Option<u32>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
pub enum GameState {
|
|
||||||
NotStarted,
|
|
||||||
Answering(u32),
|
|
||||||
Result(u32),
|
|
||||||
Completed,
|
|
||||||
}
|
|
||||||
|
|
85
src/main.rs
85
src/main.rs
|
@ -7,6 +7,7 @@ use std::{
|
||||||
};
|
};
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
|
async_trait,
|
||||||
extract::{FromRef, Multipart, Path, Query, State},
|
extract::{FromRef, Multipart, Path, Query, State},
|
||||||
http::Uri,
|
http::Uri,
|
||||||
response::{
|
response::{
|
||||||
|
@ -19,8 +20,9 @@ use axum::{
|
||||||
use axum_htmx::{HxRedirect, HxRequest};
|
use axum_htmx::{HxRedirect, HxRequest};
|
||||||
use axum_oidc::oidc::{self, EmptyAdditionalClaims, OidcApplication, OidcExtractor};
|
use axum_oidc::oidc::{self, EmptyAdditionalClaims, OidcApplication, OidcExtractor};
|
||||||
use futures_util::Stream;
|
use futures_util::Stream;
|
||||||
use game::{Game, Player};
|
use game::Game;
|
||||||
use garbage_collector::{start_gc, GarbageCollectorItem};
|
use garbage_collector::{start_gc, GarbageCollectorItem};
|
||||||
|
use question::single_choice::SingleChoiceQuestion;
|
||||||
use rand::{distributions, Rng};
|
use rand::{distributions, Rng};
|
||||||
use sailfish::TemplateOnce;
|
use sailfish::TemplateOnce;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
@ -37,6 +39,8 @@ mod game;
|
||||||
mod garbage_collector;
|
mod garbage_collector;
|
||||||
mod stream;
|
mod stream;
|
||||||
|
|
||||||
|
mod question;
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
games: Arc<RwLock<HashMap<String, Game>>>,
|
games: Arc<RwLock<HashMap<String, Game>>>,
|
||||||
|
@ -94,8 +98,8 @@ pub async fn main() {
|
||||||
|
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
.route("/", get(handle_index).post(handle_create))
|
.route("/", get(handle_index).post(handle_create))
|
||||||
.route("/:id", get(handle_play).post(handle_play_submission))
|
.route("/:id", get(handle_player).post(handle_player_answer))
|
||||||
.route("/:id/events", get(sse_play))
|
.route("/:id/events", get(sse_player))
|
||||||
.route("/:id/view", get(handle_view).post(handle_view_next))
|
.route("/:id/view", get(handle_view).post(handle_view_next))
|
||||||
.route("/:id/view/events", get(sse_view))
|
.route("/:id/view/events", get(sse_view))
|
||||||
.nest_service("/static", ServeDir::new("static"))
|
.nest_service("/static", ServeDir::new("static"))
|
||||||
|
@ -129,7 +133,7 @@ pub async fn handle_create(
|
||||||
|
|
||||||
let game_id: String = rand::thread_rng()
|
let game_id: String = rand::thread_rng()
|
||||||
.sample_iter(distributions::Alphanumeric)
|
.sample_iter(distributions::Alphanumeric)
|
||||||
.take(16)
|
.take(8)
|
||||||
.map(char::from)
|
.map(char::from)
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
@ -216,7 +220,7 @@ pub struct PlayerQuery {
|
||||||
player: Option<String>,
|
player: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn handle_play(
|
pub async fn handle_player(
|
||||||
Query(query): Query<PlayerQuery>,
|
Query(query): Query<PlayerQuery>,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
@ -233,8 +237,7 @@ pub async fn handle_play(
|
||||||
.take(32)
|
.take(32)
|
||||||
.map(char::from)
|
.map(char::from)
|
||||||
.collect();
|
.collect();
|
||||||
game.players
|
game.players.insert(player_id.to_string());
|
||||||
.insert(player_id.to_string(), Player::default());
|
|
||||||
game.on_submission.send(());
|
game.on_submission.send(());
|
||||||
|
|
||||||
Ok(Redirect::temporary(&format!(
|
Ok(Redirect::temporary(&format!(
|
||||||
|
@ -247,11 +250,12 @@ pub async fn handle_play(
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct SubmissionPayload {
|
pub struct SubmissionPayload {
|
||||||
selected: u32,
|
|
||||||
player_id: String,
|
player_id: String,
|
||||||
|
#[serde(flatten)]
|
||||||
|
values: HashMap<String, String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn handle_play_submission(
|
pub async fn handle_player_answer(
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Form(form): Form<SubmissionPayload>,
|
Form(form): Form<SubmissionPayload>,
|
||||||
|
@ -259,8 +263,7 @@ pub async fn handle_play_submission(
|
||||||
let mut games = state.games.write().await;
|
let mut games = state.games.write().await;
|
||||||
let game = games.get_mut(&id).ok_or(Error::NotFound)?;
|
let game = games.get_mut(&id).ok_or(Error::NotFound)?;
|
||||||
|
|
||||||
game.handle_submission(&form.player_id, form.selected)
|
game.handle_answer(&form.player_id, &form.values).await?;
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(Html(game.player_view(&form.player_id, true).await?))
|
Ok(Html(game.player_view(&form.player_id, true).await?))
|
||||||
}
|
}
|
||||||
|
@ -270,7 +273,7 @@ pub struct SsePlayerQuery {
|
||||||
player: String,
|
player: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn sse_play(
|
pub async fn sse_player(
|
||||||
Query(query): Query<SsePlayerQuery>,
|
Query(query): Query<SsePlayerQuery>,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
@ -294,15 +297,15 @@ struct PlayTemplate<'a> {
|
||||||
htmx: bool,
|
htmx: bool,
|
||||||
id: &'a str,
|
id: &'a str,
|
||||||
player_id: &'a str,
|
player_id: &'a str,
|
||||||
state: PlayerState<'a>,
|
state: PlayerState,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub enum PlayerState<'a> {
|
pub enum PlayerState {
|
||||||
NotStarted,
|
NotStarted,
|
||||||
Answering((u32, &'a SingleChoice)),
|
Answering { inner_body: String },
|
||||||
Waiting(u32),
|
Waiting(u32),
|
||||||
Result((&'a SingleChoice, Option<u32>)),
|
Result { inner_body: String },
|
||||||
Completed(f32),
|
Completed(f32),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -311,22 +314,33 @@ pub enum PlayerState<'a> {
|
||||||
struct ViewTemplate<'a> {
|
struct ViewTemplate<'a> {
|
||||||
htmx: bool,
|
htmx: bool,
|
||||||
id: &'a str,
|
id: &'a str,
|
||||||
quiz: &'a Quiz,
|
state: ViewerState,
|
||||||
state: ViewerState<'a>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub enum ViewerState<'a> {
|
pub enum ViewerState {
|
||||||
NotStarted((u32, String, String)),
|
NotStarted((u32, String, String)),
|
||||||
Answering((u32, &'a SingleChoice, Vec<u32>)),
|
Answering {
|
||||||
Result((u32, &'a SingleChoice, Vec<u32>)),
|
inner_body: String,
|
||||||
|
},
|
||||||
|
Result {
|
||||||
|
last_question: bool,
|
||||||
|
inner_body: String,
|
||||||
|
},
|
||||||
Completed,
|
Completed,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
pub struct Quiz {
|
pub struct Quiz {
|
||||||
pub wait_for: u64,
|
pub wait_for: u64,
|
||||||
pub fields: Vec<SingleChoice>,
|
pub questions: Vec<QuizQuestion>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
|
#[serde(tag = "type")]
|
||||||
|
pub enum QuizQuestion {
|
||||||
|
#[serde(rename = "single_choice")]
|
||||||
|
SingleChoice(SingleChoice),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
|
@ -335,3 +349,30 @@ pub struct SingleChoice {
|
||||||
answers: Vec<String>,
|
answers: Vec<String>,
|
||||||
correct: u32,
|
correct: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait Question: Send + Sync {
|
||||||
|
async fn render_player(&self, player_id: &str, show_result: bool) -> Result<String, Error>;
|
||||||
|
|
||||||
|
async fn handle_answer(
|
||||||
|
&mut self,
|
||||||
|
player_id: &str,
|
||||||
|
values: &HashMap<String, String>,
|
||||||
|
) -> Result<(), Error>;
|
||||||
|
|
||||||
|
async fn has_answered(&self, player_id: &str) -> Result<bool, Error>;
|
||||||
|
|
||||||
|
async fn answered_correctly(&self, player_id: &str) -> Result<bool, Error>;
|
||||||
|
|
||||||
|
async fn answer_count(&self) -> Result<u32, Error>;
|
||||||
|
|
||||||
|
async fn render_viewer(&self, show_result: bool) -> Result<String, Error>;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<QuizQuestion> for Box<dyn Question> {
|
||||||
|
fn from(value: QuizQuestion) -> Self {
|
||||||
|
match value {
|
||||||
|
QuizQuestion::SingleChoice(x) => Box::new(SingleChoiceQuestion::new(x)) as _,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
1
src/question.rs
Normal file
1
src/question.rs
Normal file
|
@ -0,0 +1 @@
|
||||||
|
pub mod single_choice;
|
131
src/question/single_choice.rs
Normal file
131
src/question/single_choice.rs
Normal file
|
@ -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<String, u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<String, Error> {
|
||||||
|
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<String, String>,
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
let value = values
|
||||||
|
.get("value")
|
||||||
|
.ok_or(Error::InvalidInput)?
|
||||||
|
.parse::<u32>()
|
||||||
|
.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<bool, Error> {
|
||||||
|
Ok(self.submissions.get(player_id).is_some())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn answered_correctly(&self, player_id: &str) -> Result<bool, Error> {
|
||||||
|
Ok(self
|
||||||
|
.submissions
|
||||||
|
.get(player_id)
|
||||||
|
.map(|x| *x == self.inner.correct)
|
||||||
|
.unwrap_or_default())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn answer_count(&self) -> Result<u32, Error> {
|
||||||
|
Ok(self.submissions.len() as u32)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn render_viewer(&self, show_result: bool) -> Result<String, Error> {
|
||||||
|
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],
|
||||||
|
}
|
|
@ -14,15 +14,18 @@
|
||||||
# number of seconds to wait before showing results<br/>
|
# number of seconds to wait before showing results<br/>
|
||||||
wait_for = 15<br/>
|
wait_for = 15<br/>
|
||||||
<br/>
|
<br/>
|
||||||
[[fields]]<br/>
|
[[questions]]<br/>
|
||||||
# name of the field<br/>
|
# type of the question (currently only single_choice)<br/>
|
||||||
|
type = "single_choice"<br/>
|
||||||
|
# name of the question<br/>
|
||||||
name = "Who is there?"<br/>
|
name = "Who is there?"<br/>
|
||||||
# array of possible answers<br/>
|
# array of possible answers<br/>
|
||||||
answers = [ "A", "B", "C", "D"]<br/>
|
answers = [ "A", "B", "C", "D"]<br/>
|
||||||
# index (starting at 0) of the correct answer<br/>
|
# index (starting at 0) of the correct answer<br/>
|
||||||
correct = 0<br/>
|
correct = 0<br/>
|
||||||
<br/>
|
<br/>
|
||||||
[[fields]]<br/>
|
[[questions]]<br/>
|
||||||
|
type = "single_choice"<br/>
|
||||||
name = "What is there?"<br/>
|
name = "What is there?"<br/>
|
||||||
answers = [ "A", "B", "C", "D"]<br/>
|
answers = [ "A", "B", "C", "D"]<br/>
|
||||||
correct = 0<br/>
|
correct = 0<br/>
|
||||||
|
@ -33,7 +36,7 @@
|
||||||
<span id="error" style="color: red;"></span>
|
<span id="error" style="color: red;"></span>
|
||||||
<form method="post" hx-post="" hx-target="#error" hx-swap="innerHTML" enctype="multipart/form-data">
|
<form method="post" hx-post="" hx-target="#error" hx-swap="innerHTML" enctype="multipart/form-data">
|
||||||
<input type="file" name="quizfile"/>
|
<input type="file" name="quizfile"/>
|
||||||
<button type="submit">Quiz erstellen</button>
|
<button type="submit">Create Quiz</button>
|
||||||
</form>
|
</form>
|
||||||
</main>
|
</main>
|
||||||
<script src="/static/htmx.min.js"></script>
|
<script src="/static/htmx.min.js"></script>
|
||||||
|
|
|
@ -14,42 +14,14 @@
|
||||||
<article>
|
<article>
|
||||||
<center>The Quiz hasn't started yet. Please wait for the first question.</center>
|
<center>The Quiz hasn't started yet. Please wait for the first question.</center>
|
||||||
</article>
|
</article>
|
||||||
<% } else if let PlayerState::Answering((field_id, field)) = state { %>
|
<% } else if let PlayerState::Answering{ inner_body } = state { %>
|
||||||
<article>
|
<%- inner_body %>
|
||||||
<h1><%= field.name %></h1>
|
|
||||||
<% for (index, answer) in field.answers.iter().enumerate() { %>
|
|
||||||
<form method="POST" hx-post="" hx-target="closest main">
|
|
||||||
<input type="hidden" name="player_id" value="<%= player_id %>"></input>
|
|
||||||
<input type="hidden" name="selected" value="<%= index %>"></input>
|
|
||||||
<button type="submit"><%= answer %></button>
|
|
||||||
</form>
|
|
||||||
<% } %>
|
|
||||||
</article>
|
|
||||||
<% } else if let PlayerState::Waiting(_) = state{ %>
|
<% } else if let PlayerState::Waiting(_) = state{ %>
|
||||||
<article aria-busy="true">
|
<article aria-busy="true">
|
||||||
You answered the current question. Please wait for the results.
|
You answered the current question. Please wait for the results.
|
||||||
</article>
|
</article>
|
||||||
<% } else if let PlayerState::Result((field, selected)) = state{ %>
|
<% } else if let PlayerState::Result{ inner_body } = state{ %>
|
||||||
<% if Some(field.correct) == selected { %>
|
<%- inner_body %>
|
||||||
<article>
|
|
||||||
<center><img src="/static/check.svg" width="50%"/></center>
|
|
||||||
<center><h1>Correct</h1></center>
|
|
||||||
<center><p>Your answer is correct. The correct answer is <b><%= field.answers[field.correct as usize] %></b>.</p></center>
|
|
||||||
</article>
|
|
||||||
<% } else { %>
|
|
||||||
<article>
|
|
||||||
<center><img src="/static/xmark.svg" width="50%"/></center>
|
|
||||||
<center><h1>Wrong</h1></center>
|
|
||||||
<center><p>
|
|
||||||
Your answer is incorrect. The correct answer is <b><%= field.answers[field.correct as usize] %></b>.
|
|
||||||
<% if let Some(s) = selected { %>
|
|
||||||
You answered <b><%=field.answers[s as usize]%></b>.
|
|
||||||
<% } else { %>
|
|
||||||
You didn't answer the question.
|
|
||||||
<% } %>
|
|
||||||
</p></center>
|
|
||||||
</article>
|
|
||||||
<% } %>
|
|
||||||
<% } else if let PlayerState::Completed(correct) = state { %>
|
<% } else if let PlayerState::Completed(correct) = state { %>
|
||||||
<article>
|
<article>
|
||||||
The Quiz finished. You can close this tab now. You answered <b><%= correct*100.0 %>%</b> correctly.
|
The Quiz finished. You can close this tab now. You answered <b><%= correct*100.0 %>%</b> correctly.
|
||||||
|
|
10
templates/single_choice/player.stpl
Normal file
10
templates/single_choice/player.stpl
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
<article>
|
||||||
|
<h1><%= name %></h1>
|
||||||
|
<% for (index, answer) in answers.iter().enumerate() { %>
|
||||||
|
<form method="POST" hx-post="" hx-target="closest main">
|
||||||
|
<input type="hidden" name="player_id" value="<%= player_id %>"></input>
|
||||||
|
<input type="hidden" name="value" value="<%= index %>"></input>
|
||||||
|
<button type="submit"><%= answer %></button>
|
||||||
|
</form>
|
||||||
|
<% } %>
|
||||||
|
</article>
|
20
templates/single_choice/result.stpl
Normal file
20
templates/single_choice/result.stpl
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
<% if is_correct { %>
|
||||||
|
<article>
|
||||||
|
<center><img src="/static/check.svg" width="50%"/></center>
|
||||||
|
<center><h1>Correct</h1></center>
|
||||||
|
<center><p>Your answer is correct. The correct answer is <b><%= correct_answer %></b>.</p></center>
|
||||||
|
</article>
|
||||||
|
<% } else { %>
|
||||||
|
<article>
|
||||||
|
<center><img src="/static/xmark.svg" width="50%"/></center>
|
||||||
|
<center><h1>Wrong</h1></center>
|
||||||
|
<center><p>
|
||||||
|
Your answer is incorrect. The correct answer is <b><%= correct_answer %></b>.
|
||||||
|
<% if let Some(player_answer) = player_answer { %>
|
||||||
|
You answered <b><%= player_answer %></b>.
|
||||||
|
<% } else { %>
|
||||||
|
You didn't answer the question.
|
||||||
|
<% } %>
|
||||||
|
</p></center>
|
||||||
|
</article>
|
||||||
|
<% } %>
|
54
templates/single_choice/viewer.stpl
Normal file
54
templates/single_choice/viewer.stpl
Normal file
|
@ -0,0 +1,54 @@
|
||||||
|
<h2><%= name %></h2>
|
||||||
|
<span>Total Participants: <b><%= total_submissions %></b></span>
|
||||||
|
<% if show_result { %>
|
||||||
|
<br/>
|
||||||
|
<span>The correct answer is: <b><%= correct_answer %></b></span>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
|
<canvas id="myChart"></canvas>
|
||||||
|
|
||||||
|
|
||||||
|
<script>
|
||||||
|
var ctx = document.getElementById('myChart');
|
||||||
|
var chart = new Chart(ctx, {
|
||||||
|
type: 'bar',
|
||||||
|
data: {
|
||||||
|
labels: [
|
||||||
|
<% for answer in answers.iter() { %>
|
||||||
|
"<%= answer %>",
|
||||||
|
<% } %>
|
||||||
|
],
|
||||||
|
datasets: [{
|
||||||
|
label: "# der Stimmen",
|
||||||
|
data: [
|
||||||
|
<% for submissions in submissions.iter() { %>
|
||||||
|
<%= submissions %>,
|
||||||
|
<% } %>
|
||||||
|
],
|
||||||
|
borderWidth: 1
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
legend: {
|
||||||
|
display: false
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
ticks: {
|
||||||
|
font: {
|
||||||
|
size: 64
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
beginAtZero: true,
|
||||||
|
ticks: {
|
||||||
|
font: {
|
||||||
|
size:24
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
|
@ -14,121 +14,21 @@
|
||||||
<% } %>
|
<% } %>
|
||||||
<h1>zettoIT ARS</h1>
|
<h1>zettoIT ARS</h1>
|
||||||
|
|
||||||
<% if let ViewerState::Answering((current_field, field, data)) = state { %>
|
<% if let ViewerState::Answering{ inner_body } = state { %>
|
||||||
<h2><%= field.name%></h2>
|
<%- inner_body %>
|
||||||
<span>Total Participants: <b><%= data.iter().fold(0, |acc, e| acc +e) %></b></span>
|
|
||||||
|
|
||||||
<canvas id="myChart"></canvas>
|
<button hx-post="" class="outline">Show correct Answer</button>
|
||||||
|
|
||||||
<% if (current_field+1) as usize >= quiz.fields.len() { %>
|
<% } else if let ViewerState::Result{ last_question, inner_body} = state { %>
|
||||||
|
|
||||||
|
<%- inner_body %>
|
||||||
|
|
||||||
|
<% if last_question { %>
|
||||||
<button hx-post="" class="outline">End Quiz</button>
|
<button hx-post="" class="outline">End Quiz</button>
|
||||||
<% } else { %>
|
<% } else { %>
|
||||||
<button hx-post="" class="outline">Next Question</button>
|
<button hx-post="" class="outline">Next Question</button>
|
||||||
<% } %>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
var ctx = document.getElementById('myChart');
|
|
||||||
var chart = new Chart(ctx, {
|
|
||||||
type: 'bar',
|
|
||||||
data: {
|
|
||||||
labels: [
|
|
||||||
<% for answer in field.answers.iter() { %>
|
|
||||||
"<%= answer %>",
|
|
||||||
<% } %>
|
<% } %>
|
||||||
],
|
|
||||||
datasets: [{
|
|
||||||
label: "# der Stimmen",
|
|
||||||
data: [
|
|
||||||
<% for submissions in data.iter() { %>
|
|
||||||
<%= submissions %>,
|
|
||||||
<% } %>
|
|
||||||
],
|
|
||||||
borderWidth: 1
|
|
||||||
}]
|
|
||||||
},
|
|
||||||
options: {
|
|
||||||
legend: {
|
|
||||||
display: false
|
|
||||||
},
|
|
||||||
scales: {
|
|
||||||
x: {
|
|
||||||
ticks: {
|
|
||||||
font: {
|
|
||||||
size: 64
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
y: {
|
|
||||||
beginAtZero: true,
|
|
||||||
ticks: {
|
|
||||||
font: {
|
|
||||||
size:24
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<% } else if let ViewerState::Result((current_field, field, data)) = state { %>
|
|
||||||
<h2><%= field.name%></h2>
|
|
||||||
<span>Total Participants: <b><%= data.iter().fold(0, |acc, e| acc +e) %></b></span>
|
|
||||||
<br/>
|
|
||||||
<span>The correct answer is: <b><%=field.answers[field.correct as usize]%></b></span>
|
|
||||||
|
|
||||||
<canvas id="myChart"></canvas>
|
|
||||||
|
|
||||||
<% if (current_field+1) as usize >= quiz.fields.len() { %>
|
|
||||||
<button hx-post="" class="outline">End Quiz</button>
|
|
||||||
<% } else { %>
|
|
||||||
<button hx-post="" class="outline">Next Question</button>
|
|
||||||
<% } %>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
var ctx = document.getElementById('myChart');
|
|
||||||
var chart = new Chart(ctx, {
|
|
||||||
type: 'bar',
|
|
||||||
data: {
|
|
||||||
labels: [
|
|
||||||
<% for answer in field.answers.iter() { %>
|
|
||||||
"<%= answer %>",
|
|
||||||
<% } %>
|
|
||||||
],
|
|
||||||
datasets: [{
|
|
||||||
label: "# der Stimmen",
|
|
||||||
data: [
|
|
||||||
<% for submissions in data.iter() { %>
|
|
||||||
<%= submissions %>,
|
|
||||||
<% } %>
|
|
||||||
],
|
|
||||||
borderWidth: 1
|
|
||||||
}]
|
|
||||||
},
|
|
||||||
options: {
|
|
||||||
legend: {
|
|
||||||
display: false
|
|
||||||
},
|
|
||||||
scales: {
|
|
||||||
x: {
|
|
||||||
ticks: {
|
|
||||||
font: {
|
|
||||||
size: 64
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
y: {
|
|
||||||
beginAtZero: true,
|
|
||||||
ticks: {
|
|
||||||
font: {
|
|
||||||
size:24
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
<% } else if let ViewerState::NotStarted((player, qrcode, url)) = state { %>
|
<% } else if let ViewerState::NotStarted((player, qrcode, url)) = state { %>
|
||||||
</article>
|
</article>
|
||||||
<center><%- qrcode %></center>
|
<center><%- qrcode %></center>
|
||||||
|
|
|
@ -1,11 +1,13 @@
|
||||||
wait_for = 15
|
wait_for = 15
|
||||||
|
|
||||||
[[fields]]
|
[[questions]]
|
||||||
|
type = "single_choice"
|
||||||
name = "Who is there?"
|
name = "Who is there?"
|
||||||
answers = [ "A", "B", "C", "D"]
|
answers = [ "A", "B", "C", "D"]
|
||||||
correct = 0
|
correct = 0
|
||||||
|
|
||||||
[[fields]]
|
[[questions]]
|
||||||
|
type = "single_choice"
|
||||||
name = "What is there?"
|
name = "What is there?"
|
||||||
answers = [ "A", "B", "C", "D"]
|
answers = [ "A", "B", "C", "D"]
|
||||||
correct = 0
|
correct = 0
|
||||||
|
|
Loading…
Reference in a new issue