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);