Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions client/play/BonusClient.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Expand Down Expand Up @@ -114,13 +117,31 @@ 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;
}

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) {
Expand Down
10 changes: 10 additions & 0 deletions client/play/bonuses/SoloBonusClient.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 }));
Expand Down
8 changes: 8 additions & 0 deletions client/play/bonuses/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,14 @@ <h1 id="funny-toast-text" class="me-auto text-danger"></h1>
<input class="form-check-input" id="toggle-timer" type="checkbox" role="switch" checked>
<label class="form-check-label" for="toggle-timer">Enable timer</label>
</div>
<div class="form-check form-switch">
<input class="form-check-input" id="toggle-read-bonuses-like-tossups" type="checkbox" role="switch">
<label class="form-check-label" for="toggle-read-bonuses-like-tossups">Read bonuses like tossups</label>
</div>
<div class="d-none" id="reading-speed-container">
<label for="reading-speed">Reading speed: <span id="reading-speed-display">50</span><br></label>
<input class="form-range" id="reading-speed" type="range" min="0" max="100" step="5" value="50">
</div>
<div class="mb-2"></div>
<label for="set-strictness">Strictness: <span id="strictness-display">7</span><br></label>
<input class="form-range" id="set-strictness" type="range" min="0" max="20" step="1" value="7">
Expand Down
19 changes: 18 additions & 1 deletion client/play/bonuses/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 });
Expand Down Expand Up @@ -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');
}
Expand Down
134 changes: 125 additions & 9 deletions quizbowl/BonusRoom.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
/**
Expand All @@ -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);
}
Expand All @@ -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;
Expand All @@ -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);
Expand Down Expand Up @@ -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 () {
Expand All @@ -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 }) {
Expand All @@ -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 }) {
Expand All @@ -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);
Expand Down
Loading