diff --git a/client/play/BonusClient.js b/client/play/BonusClient.js
index c1cc4edb5..f4c950f62 100644
--- a/client/play/BonusClient.js
+++ b/client/play/BonusClient.js
@@ -16,10 +16,13 @@ export const BonusClientMixin = (ClientClass) => class extends ClientClass {
case 'reveal-leadin': return this.revealLeadin(data);
case 'reveal-next-answer': return this.revealNextAnswer(data);
case 'reveal-next-part': return this.revealNextPart(data);
+ case 'set-reading-speed': return this.setReadingSpeed(data);
case 'start-bonus-answer': return this.startBonusAnswer(data);
case 'start-next-bonus': return this.startNextBonus(data);
case 'toggle-bonus-part': return this.toggleBonusPart(data);
+ case 'toggle-read-bonuses-like-tossups': return this.toggleReadBonusesLikeTossups(data);
case 'toggle-three-part-bonuses': return this.toggleThreePartBonuses(data);
+ case 'update-bonus-question': return this.updateBonusQuestion(data);
default: return super.onmessage(message);
}
}
@@ -114,6 +117,11 @@ export const BonusClientMixin = (ClientClass) => class extends ClientClass {
}
}
+ setReadingSpeed ({ readingSpeed }) {
+ document.getElementById('reading-speed').value = readingSpeed;
+ document.getElementById('reading-speed-display').textContent = readingSpeed;
+ }
+
toggleBonusPart ({ partNumber, correct }) {
document.getElementById(`checkbox-${partNumber + 1}`).checked = correct;
}
@@ -121,6 +129,19 @@ export const BonusClientMixin = (ClientClass) => class extends ClientClass {
toggleThreePartBonuses ({ threePartBonuses }) {
document.getElementById('toggle-three-part-bonuses').checked = threePartBonuses;
}
+
+ toggleReadBonusesLikeTossups ({ readBonusLikeATossup }) {
+ document.getElementById('toggle-read-bonuses-like-tossups').checked = readBonusLikeATossup;
+ document.getElementById('reading-speed-container').classList.toggle('d-none', !readBonusLikeATossup);
+ }
+
+ updateBonusQuestion ({ word, currentPartNumber }) {
+ if (currentPartNumber === -1) {
+ document.getElementById('leadin').innerHTML += word + ' ';
+ } else {
+ document.getElementById(`bonus-part-${currentPartNumber + 1}`).querySelector('p').innerHTML += word + ' ';
+ }
+ }
};
function attachEventListeners (room, socket) {
diff --git a/client/play/bonuses/SoloBonusClient.js b/client/play/bonuses/SoloBonusClient.js
index 7a4fffd38..1413639b2 100644
--- a/client/play/bonuses/SoloBonusClient.js
+++ b/client/play/bonuses/SoloBonusClient.js
@@ -119,6 +119,16 @@ export default class SoloBonusClient extends BonusClient {
window.localStorage.setItem('singleplayer-bonus-settings', JSON.stringify({ ...this.room.settings, version: settingsVersion }));
}
+ toggleReadBonusesLikeTossups ({ readBonusLikeATossup }) {
+ super.toggleReadBonusesLikeTossups({ readBonusLikeATossup });
+ window.localStorage.setItem('singleplayer-bonus-settings', JSON.stringify({ ...this.room.settings, version: settingsVersion }));
+ }
+
+ setReadingSpeed ({ readingSpeed }) {
+ super.setReadingSpeed({ readingSpeed });
+ window.localStorage.setItem('singleplayer-bonus-settings', JSON.stringify({ ...this.room.settings, version: settingsVersion }));
+ }
+
toggleTypeToAnswer ({ typeToAnswer }) {
document.getElementById('type-to-answer').checked = typeToAnswer;
window.localStorage.setItem('singleplayer-bonus-settings', JSON.stringify({ ...this.room.settings, version: settingsVersion }));
diff --git a/client/play/bonuses/index.html b/client/play/bonuses/index.html
index 34c27be24..7f17be993 100644
--- a/client/play/bonuses/index.html
+++ b/client/play/bonuses/index.html
@@ -92,6 +92,14 @@
+
+
+
+
+
+
+
+
diff --git a/client/play/bonuses/index.jsx b/client/play/bonuses/index.jsx
index 649b0fa93..76e217bdb 100644
--- a/client/play/bonuses/index.jsx
+++ b/client/play/bonuses/index.jsx
@@ -9,7 +9,7 @@ import SoloBonusClient from './SoloBonusClient.js';
const modeVersion = '2025-01-14';
const queryVersion = '2025-05-07';
-const settingsVersion = '2024-11-02';
+const settingsVersion = '2026-05-10';
const USER_ID = 'user';
const TEAM_ID = 'team';
@@ -38,11 +38,24 @@ document.getElementById('local-packet-input').addEventListener('change', functio
reader.readAsText(file);
});
+document.getElementById('reading-speed').addEventListener('change', function () {
+ socket.sendToServer({ type: 'set-reading-speed', readingSpeed: this.value });
+});
+
+document.getElementById('reading-speed').addEventListener('input', function () {
+ document.getElementById('reading-speed-display').textContent = this.value;
+});
+
document.getElementById('toggle-randomize-order').addEventListener('click', function () {
this.blur();
socket.sendToServer({ type: 'toggle-randomize-order', randomizeOrder: this.checked });
});
+document.getElementById('toggle-read-bonuses-like-tossups').addEventListener('click', function () {
+ this.blur();
+ socket.sendToServer({ type: 'toggle-read-bonuses-like-tossups', readBonusLikeATossup: this.checked });
+});
+
document.getElementById('toggle-three-part-bonuses').addEventListener('click', function () {
this.blur();
socket.sendToServer({ type: 'toggle-three-part-bonuses', threePartBonuses: this.checked });
@@ -114,6 +127,10 @@ if (window.localStorage.getItem('singleplayer-bonus-settings')) {
socket.sendToServer({ type: 'set-strictness', ...savedSettings });
socket.sendToServer({ type: 'toggle-timer', ...savedSettings });
socket.sendToServer({ type: 'toggle-type-to-answer', ...savedSettings });
+ socket.sendToServer({ type: 'toggle-read-bonuses-like-tossups', readBonusLikeATossup: savedSettings.readBonusLikeATossup });
+ if (savedSettings.readingSpeed !== undefined) {
+ socket.sendToServer({ type: 'set-reading-speed', readingSpeed: savedSettings.readingSpeed });
+ }
} catch {
window.localStorage.removeItem('singleplayer-bonus-settings');
}
diff --git a/quizbowl/BonusRoom.js b/quizbowl/BonusRoom.js
index eb75ab0f1..a339e5e7b 100644
--- a/quizbowl/BonusRoom.js
+++ b/quizbowl/BonusRoom.js
@@ -5,6 +5,8 @@ export const BonusRoomMixin = (QuestionRoomClass) => class extends QuestionRoomC
constructor (name, categoryManager, supportedQuestionTypes = ['bonuses']) {
super(name, categoryManager, supportedQuestionTypes);
+ this.timeoutId = null;
+
this.bonus = {};
this.bonusProgress = BONUS_PROGRESS_ENUM.NOT_STARTED;
/**
@@ -17,18 +19,29 @@ export const BonusRoomMixin = (QuestionRoomClass) => class extends QuestionRoomC
*/
this.pointsPerPart = [];
+ this.bonusQuestionSplit = [];
+ this.bonusWordIndex = 0;
+
this.query = {
threePartBonuses: true,
...this.query
};
+
+ this.settings = {
+ ...this.settings,
+ readBonusLikeATossup: false,
+ readingSpeed: 50
+ };
}
async message ({ userId, username }, message) {
switch (message.type) {
case 'give-answer': return this.giveBonusAnswer({ userId, username }, message);
case 'next': return this.next({ userId, username }, message);
+ case 'set-reading-speed': return this.setReadingSpeed({ userId, username }, message);
case 'start-bonus-answer': return this.startBonusAnswer({ userId, username }, message);
case 'toggle-bonus-part': return this.toggleBonusPart({ userId, username }, message);
+ case 'toggle-read-bonuses-like-tossups': return this.toggleReadBonusesLikeTossups({ userId, username }, message);
case 'toggle-three-part-bonuses': return this.toggleThreePartBonuses({ userId, username }, message);
default: return super.message({ userId, username }, message);
}
@@ -45,6 +58,7 @@ export const BonusRoomMixin = (QuestionRoomClass) => class extends QuestionRoomC
if (this.bonusProgress === BONUS_PROGRESS_ENUM.READING && !this.settings.skip) { return false; }
clearInterval(this.timer.interval);
+ clearTimeout(this.timeoutId);
this.emitMessage({ type: 'timer-update', timeRemaining: 0 });
const lastPartRevealed = this.bonusProgress === BONUS_PROGRESS_ENUM.LAST_PART_REVEALED;
@@ -69,6 +83,7 @@ export const BonusRoomMixin = (QuestionRoomClass) => class extends QuestionRoomC
this.liveAnswer = '';
clearInterval(this.timer.interval);
+ clearTimeout(this.timeoutId);
this.emitMessage({ type: 'timer-update', timeRemaining: ANSWER_TIME_LIMIT * 10 });
const { directive, directedPrompt } = this.checkAnswer(this.bonus.answers[this.currentPartNumber], givenAnswer);
@@ -96,7 +111,15 @@ export const BonusRoomMixin = (QuestionRoomClass) => class extends QuestionRoomC
}
revealLeadin () {
- this.emitMessage({ type: 'reveal-leadin', leadin: this.bonus.leadin });
+ if (this.settings.readBonusLikeATossup) {
+ this.emitMessage({ type: 'reveal-leadin', leadin: '' });
+ const leadinSanitized = this.bonus.leadin_sanitized ?? '';
+ this.startReadingBonusText(leadinSanitized, () => {
+ this.revealNextPart();
+ });
+ } else {
+ this.emitMessage({ type: 'reveal-leadin', leadin: this.bonus.leadin });
+ }
}
revealNextAnswer () {
@@ -116,13 +139,28 @@ export const BonusRoomMixin = (QuestionRoomClass) => class extends QuestionRoomC
if (this.bonusProgress === BONUS_PROGRESS_ENUM.LAST_PART_REVEALED) { return; }
this.currentPartNumber++;
- this.emitMessage({
- type: 'reveal-next-part',
- bonusEligibleTeamId: this.bonusEligibleTeamId,
- currentPartNumber: this.currentPartNumber,
- part: this.bonus.parts[this.currentPartNumber],
- value: this.getPartValue()
- });
+
+ if (this.settings.readBonusLikeATossup) {
+ this.emitMessage({
+ type: 'reveal-next-part',
+ bonusEligibleTeamId: this.bonusEligibleTeamId,
+ currentPartNumber: this.currentPartNumber,
+ part: '',
+ value: this.getPartValue()
+ });
+ const partSanitized = this.bonus.parts_sanitized?.[this.currentPartNumber] ?? '';
+ this.startReadingBonusText(partSanitized, () => {
+ this.autoStartBonusAnswer();
+ });
+ } else {
+ this.emitMessage({
+ type: 'reveal-next-part',
+ bonusEligibleTeamId: this.bonusEligibleTeamId,
+ currentPartNumber: this.currentPartNumber,
+ part: this.bonus.parts[this.currentPartNumber],
+ value: this.getPartValue()
+ });
+ }
}
startBonusAnswer ({ userId, username }) {
@@ -138,12 +176,15 @@ export const BonusRoomMixin = (QuestionRoomClass) => class extends QuestionRoomC
this.bonus = await this.getNextQuestion('bonuses');
this.queryingQuestion = false;
if (!this.bonus) { return; }
+ clearTimeout(this.timeoutId);
this.emitMessage({ type: 'start-next-bonus', packetLength: this.packet.bonuses.length, bonus: this.bonus, userId, username });
this.currentPartNumber = -1;
this.pointsPerPart = [];
this.bonusProgress = BONUS_PROGRESS_ENUM.READING;
this.revealLeadin();
- this.revealNextPart();
+ if (!this.settings.readBonusLikeATossup) {
+ this.revealNextPart();
+ }
}
toggleBonusPart ({ userId, username }, { partNumber, correct }) {
@@ -157,6 +198,81 @@ export const BonusRoomMixin = (QuestionRoomClass) => class extends QuestionRoomC
this.adjustQuery(['threePartBonuses'], [threePartBonuses]);
this.emitMessage({ type: 'toggle-three-part-bonuses', threePartBonuses, username });
}
+
+ /**
+ * Automatically starts the bonus answer after word-by-word reading is complete.
+ * Finds the appropriate user to answer and calls startBonusAnswer.
+ */
+ autoStartBonusAnswer () {
+ let userId, username;
+ if (this.bonusEligibleTeamId) {
+ const player = Object.values(this.players).find(p => p.teamId === this.bonusEligibleTeamId);
+ userId = player?.userId;
+ username = player?.username ?? '';
+ }
+ if (!userId) {
+ userId = Object.keys(this.players)[0];
+ username = userId ? (this.players[userId].username ?? '') : '';
+ }
+ if (!userId) { return; }
+ this.startBonusAnswer({ userId, username });
+ }
+
+ /**
+ * Splits sanitizedText into words and begins reading them word by word.
+ * Calls onComplete when all words have been emitted.
+ * @param {string} sanitizedText
+ * @param {() => void} onComplete
+ */
+ startReadingBonusText (sanitizedText, onComplete) {
+ this.bonusQuestionSplit = sanitizedText.split(' ').filter(word => word !== '');
+ this.bonusWordIndex = 0;
+ this.readBonusWord(Date.now(), onComplete);
+ }
+
+ /**
+ * Reads the next word from bonusQuestionSplit, emitting it to all clients.
+ * Schedules itself recursively until all words are read.
+ * @param {number} expectedReadTime
+ * @param {() => void} onComplete
+ */
+ readBonusWord (expectedReadTime, onComplete) {
+ if (this.bonusWordIndex >= this.bonusQuestionSplit.length) {
+ onComplete();
+ return;
+ }
+
+ const word = this.bonusQuestionSplit[this.bonusWordIndex++];
+ this.emitMessage({ type: 'update-bonus-question', word, currentPartNumber: this.currentPartNumber });
+
+ let time = Math.log(word.length) + 1;
+ if ((word.endsWith('.') && word.charCodeAt(word.length - 2) > 96 && word.charCodeAt(word.length - 2) < 123) ||
+ word.slice(-2) === '.\u201d' || word.slice(-2) === '!\u201d' || word.slice(-2) === '?\u201d') {
+ time += 2.5;
+ } else if (word.endsWith(',') || word.slice(-2) === ',\u201d') {
+ time += 1.5;
+ }
+
+ time = time * 0.9 * (140 - this.settings.readingSpeed);
+ const delay = time - Date.now() + expectedReadTime;
+
+ this.timeoutId = setTimeout(() => {
+ this.readBonusWord(time + expectedReadTime, onComplete);
+ }, delay);
+ }
+
+ setReadingSpeed ({ username }, { readingSpeed }) {
+ if (isNaN(readingSpeed)) { return false; }
+ if (readingSpeed > 100) { readingSpeed = 100; }
+ if (readingSpeed < 0) { readingSpeed = 0; }
+ this.settings.readingSpeed = readingSpeed;
+ this.emitMessage({ type: 'set-reading-speed', username, readingSpeed });
+ }
+
+ toggleReadBonusesLikeTossups ({ username }, { readBonusLikeATossup }) {
+ this.settings.readBonusLikeATossup = !!readBonusLikeATossup;
+ this.emitMessage({ type: 'toggle-read-bonuses-like-tossups', readBonusLikeATossup: this.settings.readBonusLikeATossup, username });
+ }
};
const BonusRoom = BonusRoomMixin(QuestionRoom);