aboutsummaryrefslogtreecommitdiff
path: root/static/bv/tutorial/tutorial.js
diff options
context:
space:
mode:
Diffstat (limited to 'static/bv/tutorial/tutorial.js')
-rw-r--r--static/bv/tutorial/tutorial.js1011
1 files changed, 1011 insertions, 0 deletions
diff --git a/static/bv/tutorial/tutorial.js b/static/bv/tutorial/tutorial.js
new file mode 100644
index 0000000..33f9258
--- /dev/null
+++ b/static/bv/tutorial/tutorial.js
@@ -0,0 +1,1011 @@
+class BracketCityTutorial {
+ /**
+ * Constructor for the tutorial mode
+ * @param {HTMLElement} rootElement - The container element for the tutorial
+ */
+ constructor(rootElement) {
+ this.root = rootElement;
+
+ // Detect device type
+ this.isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
+
+ // Track keyboard layout for mobile
+ this.isAltKeyboard = false;
+
+ // Hard-coded tutorial puzzle data
+ this.PUZZLE_DATA = {
+ initialPuzzle: "[where [opposite of clean] dishes pile up] or [exercise in a [game played with a cue ball]]",
+ puzzleDate: "Tutorial Mode",
+ completionText: "🎉 Tutorial Complete! 🎉",
+ solutions: {
+ "exercise in a pool": "swim",
+ "game played with a cue ball": "pool",
+ "where dirty dishes pile up": "sink",
+ "opposite of clean": "dirty"
+ }
+ };
+
+ // Initialize the game state
+ this.state = {
+ displayState: this.PUZZLE_DATA.initialPuzzle,
+ solvedExpressions: new Set(),
+ solvedOrder: [], // New array to track order
+ message: '',
+ totalKeystrokes: 0,
+ activeClues: this.findActiveClues(this.PUZZLE_DATA.initialPuzzle),
+ hintModeClues: new Set(),
+ peekedClues: new Set(),
+ megaPeekedClues: new Set(),
+ tutorialStep: 0
+ };
+
+ // Tutorial guidance messages for each step
+ this.tutorialSteps = [
+ {
+ message: "In Bracket Village you can solve <strong>any clue</strong> just by submitting an answer<div class='small-break'></div>No need to click, just type the answer to any <span class='spotlight'>highlighted clue</span> and hit enter!<div class='small-break'></div>Keep guessing until you get one!",
+ highlight: "clue"
+ },
+ {
+ message: "Nice! <span class='correct'>pool</span> is correct!<div class='small-break'></div>Clues are often <strong>nested</strong> within other clues<div class='small-break'></div>You'll need to solve <span class='spotlight'>opposite of clean</span> to reveal its parent clue about dishes",
+ condition: expressions => expressions.size === 1 && expressions.has("game played with a cue ball"),
+ highlight: "none"
+ },
+ {
+ message: "Nice! <span class='correct'>dirty</span> is correct!<div class='small-break'></div>Clues are often <strong>nested</strong> within other clues<div class='small-break'></div>Now solve <span class='spotlight'>game played with a cue ball</span> to reveal the parent clue about exercise",
+ condition: expressions => expressions.size === 1 && expressions.has("opposite of clean"),
+ highlight: "none"
+ },
+ {
+ message: "Excellent! <span class='correct'>pool</span> is correct!<div class='small-break'></div>Now both <strong>parent clues</strong> are revealed, so they are both <span class='spotlight'>highlighted</span> and solvable<div class='small-break'></div>Go ahead and solve one",
+ condition: expressions => expressions.size === 2 && expressions.has("opposite of clean") && expressions.has("game played with a cue ball") && !expressions.has("where dirty dishes pile up") && !expressions.has("exercise in a pool") && this.state.solvedOrder[0] === "opposite of clean",
+ highlight: "none"
+ },
+ {
+ message: "Excellent! <span class='correct'>dirty</span> is correct!<div class='small-break'></div>Now both <strong>parent clues</strong> are revealed, so they are both <span class='spotlight'>highlighted</span> and solvable<div class='small-break'></div>Go ahead and solve one",
+ condition: expressions => expressions.size === 2 && expressions.has("opposite of clean") && expressions.has("game played with a cue ball") && !expressions.has("where dirty dishes pile up") && !expressions.has("exercise in a pool") && this.state.solvedOrder[0] === "game played with a cue ball",
+ highlight: "none"
+ },
+ {
+ message: "Ok fine you got <span class='correct'>sink</span> instead<div class='small-break'></div>You still need to solve <span class='spotlight'>game played with a cue ball</span><div class='small-break'></div>Looking at the <strong>parent clue</strong> text can help...",
+ condition: expressions => expressions.size === 2 && expressions.has("opposite of clean") && expressions.has("where dirty dishes pile up") && !expressions.has("game played with a cue ball") && !expressions.has("exercise in a pool"),
+ highlight: "none"
+ },
+ {
+ message: "Ok fine you got <span class='correct'>swim</span> instead<div class='small-break'></div>You still need to solve <span class='spotlight'>opposite of clean</span><div class='small-break'></div>Looking at the <strong>parent clue</strong> text can help...",
+ condition: expressions => expressions.size === 2 && expressions.has("exercise in a pool") && expressions.has("game played with a cue ball") && !expressions.has("opposite of clean") && !expressions.has("where dirty dishes pile up"),
+ highlight: "none"
+ },
+ {
+ message: "Yee-haw <span class='correct'>swim</span> is right. Only one more <span class='spotlight'>clue</span> to go...<div class='small-break'></div>Try clicking it to reveal the first letter, that's called a <strong>\"peek\"</strong><div class='small-break'></div>Then go ahead and finish!",
+ condition: expressions => expressions.size === 3 && expressions.has("exercise in a pool") && expressions.has("game played with a cue ball") && expressions.has("opposite of clean") && !expressions.has("where dirty dishes pile up") && this.state.solvedOrder[2] === "exercise in a pool",
+ highlight: "none"
+ },
+ {
+ message: "Yee-haw <span class='correct'>sink</span> is right. Only one more <span class='spotlight'>clue</span> to go...<div class='small-break'></div>Try clicking it to reveal the first letter, that's called a <strong>\"peek\"</strong><div class='small-break'></div>Then go ahead and finish!",
+ condition: expressions => expressions.size === 3 && expressions.has("where dirty dishes pile up") && expressions.has("game played with a cue ball") && expressions.has("opposite of clean") && !expressions.has("exercise in a pool") && this.state.solvedOrder[2] === "where dirty dishes pile up",
+ highlight: "none"
+ },
+ {
+ message: "Yee-haw <span class='correct'>dirty</span> is right. Only one more <span class='spotlight'>clue</span> to go...<div class='small-break'></div>Try clicking it to reveal the first letter, that's called a <strong>\"peek\"</strong><div class='small-break'></div>Then go ahead and finish!",
+ condition: expressions => expressions.size === 3 && expressions.has("opposite of clean") && expressions.has("game played with a cue ball") && expressions.has("exercise in a pool") && this.state.solvedOrder[2] === "opposite of clean",
+ highlight: "none"
+ },
+ {
+ message: "Yee-haw <span class='correct'>pool</span> is right. Only one more <span class='spotlight'>clue</span> to go...<div class='small-break'></div>Try clicking it to reveal the first letter, that's called a <strong>\"peek\"</strong><div class='small-break'></div>Then go ahead and finish!",
+ condition: expressions => expressions.size === 3 && expressions.has("opposite of clean") && expressions.has("game played with a cue ball") && expressions.has("where dirty dishes pile up") && this.state.solvedOrder[2] === "game played with a cue ball",
+ highlight: "none"
+ },
+ {
+ message: "<strong>Just to recap:</strong><div class='small-break'></div>* you can solve any <span class='spotlight'>highlighted clue</span> \u{2013} just type your guess and hit enter<div class='small-break'></div>* click once on a clue to <strong>peek</strong> at the first letter, twice to <span class='correct'>reveal</span> the answer",
+ condition: expressions => !this.state.displayState.includes('['),
+ highlight: "none",
+ isComplete: true
+ }
+ ];
+
+ // Initialize the UI
+ this.initializeGame();
+ }
+
+
+ /**
+ * Initializes the tutorial game
+ */
+ initializeGame() {
+ // Generate the initial HTML
+ this.root.innerHTML = this.generateInitialHTML();
+
+ // Setup input handlers
+ this.setupInputHandlers();
+
+ // Render the initial state
+ this.render();
+
+ // Setup tutorial guidance
+ this.updateTutorialGuidance();
+
+ // Setup clue click handlers for peeking
+ this.setupClueClickHandlers();
+ }
+
+ /**
+ * Generates the initial HTML for the tutorial
+ */
+ generateInitialHTML() {
+ return `
+ <div class="puzzle-container">
+ ${this.generateHeaderHTML()}
+ <div class="puzzle-content">
+ ${this.generateTutorialGuidanceHTML()}
+ ${this.generatePuzzleDisplayHTML()}
+
+ ${this.generateInputContainerHTML()}
+ </div>
+ </div>
+ `;
+ }
+
+ /**
+ * Generates the header HTML
+ */
+ generateHeaderHTML() {
+ return `
+ <div class="puzzle-header">
+ <h1>[Bracket Village]</h1>
+ <button class="exit-button">X</button>
+ <div class="nav-container">
+ <div class="puzzle-date">How To Play</div>
+ </div>
+ </div>
+ `;
+ }
+
+ /**
+ * Generates the puzzle display HTML with fixed height
+ */
+ generatePuzzleDisplayHTML() {
+ return `<div class="puzzle-display tutorial-puzzle-display"></div>`;
+ }
+
+ /**
+ * Generates the tutorial guidance container HTML
+ */
+ generateTutorialGuidanceHTML() {
+ return `
+ <div class="tutorial-guidance">
+ <div class="tutorial-message"></div>
+ </div>
+ `;
+ }
+
+ /**
+ * Generates the input container HTML based on device type
+ */
+ generateInputContainerHTML() {
+ if (this.isMobile) {
+ return `
+ <div class="input-container">
+ <div class="message"></div>
+ <div class="input-submit-wrapper" style="display: flex; gap: 0.5rem; margin-bottom: 0.5rem;">
+ <div class="custom-input" style="flex-grow: 1; height: 44px; display: flex; align-items: center;">
+ <span class="placeholder">type any answer...</span>
+ </div>
+ <button
+ class="mobile-submit-button"
+ style="height: 44px; background-color: #2563eb; color: white; padding: 0 1rem; border-radius: 6px; font-weight: 500; border: none;"
+ >
+ [submit]
+ </button>
+ </div>
+ <div class="custom-keyboard">
+ ${this.generateKeyboardButtonsHTML()}
+ </div>
+ </div>
+ `;
+ }
+
+ return `
+ <div class="input-container">
+ <div class="input-submit-wrapper" style="display: flex; gap: 0.5rem;">
+ <input
+ type="text"
+ placeholder="type any answer..."
+ class="answer-input"
+ autocomplete="off"
+ autocapitalize="off"
+ >
+ <button
+ class="submit-answer"
+ >
+ [submit]
+ </button>
+ </div>
+ <div class="message"></div>
+ </div>
+ `;
+ }
+
+ /**
+ * Generates mobile keyboard buttons HTML
+ */
+ generateKeyboardButtonsHTML() {
+ const mainLayout = [
+ { keys: 'qwertyuiop', columns: 10 },
+ { keys: 'asdfghjkl', columns: 10 },
+ { keys: [
+ { key: '123', display: '123', small: true },
+ 'z', 'x', 'c', 'v', 'b', 'n', 'm',
+ { key: 'backspace', display: '\u{232B}', wide: true }
+ ], columns: 10 }
+ ];
+
+ const altLayout = [
+ { keys: '1234567890', columns: 10 },
+ { keys: '-/:;()$&@"', columns: 10 },
+ { keys: [
+ { key: 'ABC', display: 'ABC', small: true },
+ '.', ',', '?', '!', "'", "=", "+",
+ { key: 'backspace', display: '\u{232B}', wide: true }
+ ], columns: 10 }
+ ];
+
+ const rows = this.isAltKeyboard ? altLayout : mainLayout;
+
+ let html = ``;
+
+ rows.forEach((row, rowIndex) => {
+ // Adding margin for middle row on default layout since it's only 9 keys vs 10
+ const leftPadding = (rowIndex === 1 && !this.isAltKeyboard) ? 'margin-left: 5%' : '';
+ html += `<div class="keyboard-row" style="${leftPadding}">`;
+
+ const keys = Array.isArray(row.keys) ? row.keys : row.keys.split('');
+ keys.forEach(keyItem => {
+ if (typeof keyItem === 'string') {
+ html += this.generateKeyboardKeyHTML(keyItem);
+ } else {
+ html += this.generateSpecialKeyHTML(keyItem);
+ }
+ });
+
+ html += '</div>';
+ });
+
+ return html;
+ }
+
+ /**
+ * Generates a special keyboard key HTML
+ */
+ generateSpecialKeyHTML(keyItem) {
+ const fontSize = keyItem.small ? '0.875rem' : '1.125rem';
+
+ return `
+ <button class="keyboard-key" data-key="${keyItem.key}" style="
+ grid-column: ${keyItem.wide ? 'span 2' : 'span 1'};
+ font-size: ${fontSize};
+ ">
+ ${keyItem.display}
+ </button>
+ `;
+ }
+
+ /**
+ * Generates a regular keyboard key HTML
+ */
+ generateKeyboardKeyHTML(key) {
+ return `
+ <button class="keyboard-key" data-key="${key}">
+ ${key}
+ </button>
+ `;
+ }
+
+ /**
+ * Sets up input handlers for desktop or mobile
+ */
+ setupInputHandlers() {
+ if (this.isMobile) {
+ this.setupMobileInput();
+ } else {
+ this.setupDesktopInput();
+ }
+
+ // Setup exit button (formerly help button)
+ const exitButton = this.root.querySelector('.exit-button');
+ if (exitButton) {
+ exitButton.addEventListener('click', () => {
+ if (confirm('Are you sure you want to exit the tutorial?')) {
+ window.location.href = '/bv';
+ }
+ });
+ }
+ }
+
+ /**
+ * Sets up desktop input handling
+ */
+ setupDesktopInput() {
+ this.answerInput = this.root.querySelector('.answer-input');
+ const submitButton = this.root.querySelector('.submit-answer');
+
+ if (!this.answerInput || !submitButton) {
+ console.error('Required input elements not found');
+ return;
+ }
+
+ // Handle submit button click
+ submitButton.addEventListener('click', () => {
+ this.handleSubmission();
+ });
+
+ // Handle Enter key with visual feedback
+ this.answerInput.addEventListener('keypress', (e) => {
+ if (e.key === 'Enter') {
+ e.preventDefault();
+
+ // Apply visual feedback style directly to the button
+ submitButton.style.transform = 'scale(0.95)';
+ submitButton.style.backgroundColor = '#1e40af';
+
+ // Process the submission
+ this.handleSubmission();
+
+ // Remove the styles after a short delay
+ setTimeout(() => {
+ submitButton.style.transform = '';
+ submitButton.style.backgroundColor = '';
+ }, 85); // Slightly longer for better visibility
+ }
+ });
+
+ this.answerInput.addEventListener('keydown', (e) => {
+ this.handleKeyDown(e);
+ });
+
+ this.answerInput.addEventListener('paste', (e) => {
+ e.preventDefault();
+ });
+
+ this.answerInput.focus();
+ }
+
+ /**
+ * Sets up mobile input handling
+ */
+ setupMobileInput() {
+ this.customInputValue = '';
+ this.customInputEl = this.root.querySelector('.custom-input');
+
+ if (!this.customInputEl) {
+ console.error('Custom input display element not found');
+ return;
+ }
+
+ // Clean up old placeholder if it exists
+ const oldPlaceholder = this.customInputEl.querySelector('.placeholder');
+ if (oldPlaceholder) {
+ oldPlaceholder.remove();
+ }
+
+ // Create fresh placeholder
+ const placeholderEl = document.createElement('span');
+ placeholderEl.className = 'placeholder';
+ placeholderEl.style.color = '#9ca3af';
+ placeholderEl.textContent = 'type any answer...';
+ this.customInputEl.appendChild(placeholderEl);
+
+ // Set up the mobile submit button
+ const mobileSubmitButton = this.root.querySelector('.mobile-submit-button');
+ if (mobileSubmitButton) {
+ mobileSubmitButton.addEventListener('click', () => {
+ this.handleSubmission();
+ });
+ }
+
+ // Attach fresh click listeners to keyboard keys
+ const keyboardKeys = this.root.querySelectorAll('.keyboard-key');
+ keyboardKeys.forEach(keyEl => {
+ keyEl.addEventListener('click', () => {
+ const key = keyEl.getAttribute('data-key');
+ if (key === 'backspace') {
+ this.customInputValue = this.customInputValue.slice(0, -1);
+ } else if (key === '123' || key === 'ABC') {
+ this.isAltKeyboard = !this.isAltKeyboard;
+ // Re-render the keyboard
+ const keyboardContainer = this.root.querySelector('.custom-keyboard');
+ if (keyboardContainer) {
+ keyboardContainer.innerHTML = this.generateKeyboardButtonsHTML();
+ }
+ // Re-attach event listeners while preserving input
+ this.setupMobileInput();
+ return;
+ } else {
+ this.customInputValue += key;
+ this.state.totalKeystrokes++;
+ }
+
+ this.updateCustomInputDisplay();
+ });
+
+ // Prevent dragging/swiping on the key
+ keyEl.addEventListener('touchmove', (e) => {
+ e.preventDefault();
+ }, { passive: false });
+ });
+
+ // Enable :active states on iOS
+ document.addEventListener('touchstart', () => {}, false);
+
+ // Update display to show current input value
+ this.updateCustomInputDisplay();
+ }
+
+ handleSubmission() {
+ const input = this.isMobile ? this.customInputValue : this.answerInput.value;
+ if (!input || !input.trim()) return;
+
+ const normalizedInput = this.normalizeInput(input);
+ const match = this.findMatchingExpression(normalizedInput);
+
+ if (match) {
+ this.solveExpression(match);
+ }
+
+ // Clear input regardless of match
+ if (this.isMobile) {
+ this.customInputValue = '';
+ this.updateCustomInputDisplay();
+ } else {
+ this.answerInput.value = '';
+ this.answerInput.focus();
+ }
+ }
+ /**
+ * Updates the custom input display for mobile
+ */
+ updateCustomInputDisplay() {
+ if (!this.customInputEl) return;
+
+ // Find or create placeholder
+ let placeholderEl = this.customInputEl.querySelector('.placeholder');
+ if (!placeholderEl) {
+ placeholderEl = document.createElement('span');
+ placeholderEl.className = 'placeholder';
+ placeholderEl.style.color = '#9ca3af';
+ placeholderEl.textContent = 'start typing answers...';
+ }
+
+ // Clear the input
+ this.customInputEl.innerHTML = '';
+
+ // Show either placeholder or input value
+ if (this.customInputValue.length > 0) {
+ this.customInputEl.textContent = this.customInputValue;
+ } else {
+ this.customInputEl.appendChild(placeholderEl);
+ }
+ }
+
+ /**
+ * Sets up clue click handlers for peeking functionality
+ */
+ setupClueClickHandlers() {
+ const puzzleDisplay = this.root.querySelector('.puzzle-display');
+ if (!puzzleDisplay) return;
+
+ puzzleDisplay.addEventListener('click', (event) => {
+ const clueElement = event.target.closest('.active-clue');
+ if (!clueElement) return;
+
+ // Find the clicked expression
+ const cleanText = this.state.displayState.replace(/<\/?[^>]+(>|$)/g, '');
+ const activeClues = this.findActiveClues(cleanText);
+ const cluePosition = Array.from(puzzleDisplay.querySelectorAll('.active-clue')).indexOf(clueElement);
+
+ if (cluePosition >= 0 && cluePosition < activeClues.length) {
+ const expression = activeClues[cluePosition].expression.trim();
+ this.toggleHintMode(expression);
+
+ // Return focus to input on desktop
+ if (!this.isMobile && this.answerInput) {
+ this.answerInput.focus();
+ }
+ }
+ });
+ }
+
+ /**
+ * Shows the help dialog
+ */
+ showHelp() {
+ const helpContent = `
+ <div style="text-align: center; font-weight: bold; margin: 0 0 0.5em 0;">Welcome to the Bracket Village Tutorial</div>
+ <div style="margin: 0.9em 0;">* In Bracket Village, you solve <mark style="background-color: #fff9c4;">[active clues]</mark> by typing the answers</div>
+ <div style="margin: 0.9em 0;">* You can tap any <mark style="background-color: #fff9c4;">[active clue]</mark> to peek at its first letter, or tap again to reveal the full answer</div>
+ <div style="margin: 0.9em 0;">* This tutorial will guide you through the basics</div>
+ <div style="margin: 0.9em 0;">* Tap anywhere to continue</div>
+ `;
+
+ const puzzleDisplay = this.root.querySelector('.puzzle-display');
+ if (!puzzleDisplay) return;
+
+ // Store the current content
+ this.previousDisplayContent = puzzleDisplay.innerHTML;
+
+ // Show help content
+ puzzleDisplay.innerHTML = helpContent;
+
+ // Add click handler to close help
+ const closeHandler = () => {
+ puzzleDisplay.innerHTML = this.previousDisplayContent;
+ puzzleDisplay.removeEventListener('click', closeHandler);
+ // Return focus to input on desktop
+ if (!this.isMobile && this.answerInput) {
+ this.answerInput.focus();
+ }
+ };
+
+ puzzleDisplay.addEventListener('click', closeHandler);
+ }
+
+ /**
+ * Main render method to update the UI
+ */
+ render() {
+ const puzzleDisplay = this.root.querySelector('.puzzle-display');
+ if (!puzzleDisplay) return;
+
+ // Apply highlighting to active clues
+ const highlightedState = this.applyActiveClueHighlights(this.state.displayState);
+ puzzleDisplay.innerHTML = highlightedState;
+
+ // Check if puzzle is complete
+ if (this.isPuzzleComplete()) {
+ this.renderCompletionState();
+ }
+ }
+
+ /**
+ * Applies highlighting to active clues
+ * @param {string} text - Current puzzle text
+ * @returns {string} HTML with highlights applied
+ */
+ applyActiveClueHighlights(text) {
+ const cleanText = text.replace(/<\/?span[^>]*(>|$)/g, '');
+ const activeClues = this.findActiveClues(cleanText);
+
+ // Sort clues by start position in reverse order
+ activeClues.sort((a, b) => b.start - a.start);
+
+ let highlightedText = cleanText;
+
+ activeClues.forEach(clue => {
+ const expressionText = clue.expression.trim();
+ const before = highlightedText.slice(0, clue.start);
+ let clueText = highlightedText.slice(clue.start, clue.end);
+ const after = highlightedText.slice(clue.end);
+
+ // Handle different clue states
+ if (!clueText.includes('[') && !this.state.hintModeClues.has(expressionText)) {
+ // Already solved clue
+ highlightedText = before + clueText + after;
+ } else {
+ // Active or hint mode clue
+ if (this.state.hintModeClues.has(expressionText)) {
+ const solution = this.PUZZLE_DATA.solutions[expressionText];
+ // Show just the first letter
+ if (solution && !clueText.includes(`(`)) {
+ // Get the first letter of the solution
+ const firstLetter = solution.charAt(0).toUpperCase();
+ // Remove closing bracket, add first letter hint, re-add closing bracket
+ clueText = clueText.slice(0, -1) + ` (${firstLetter})]`;
+ }
+ }
+
+ // Ensure we're only wrapping the exact clue text with brackets included
+ highlightedText = before + `<span class="active-clue">${clueText}</span>` + after;
+ }
+ });
+
+ return highlightedText;
+ }
+
+ /**
+ * Renders the completion state while maintaining fixed height
+ */
+ renderCompletionState() {
+ const inputContainer = this.root.querySelector('.input-container');
+ const puzzleDisplay = this.root.querySelector('.puzzle-display');
+
+ if (!inputContainer || !puzzleDisplay) return;
+
+ // Hide input container
+ inputContainer.style.display = 'none';
+
+ // Add completion class but ensure height is preserved
+ puzzleDisplay.classList.add('completed');
+
+ // Add a tutorial completion message at the top
+ const completionHTML = `
+ <div class="tutorial-completion">
+ <div class="tutorial-completion-title">🎉 Tutorial Complete! 🎉</div>
+ <div class="tutorial-completion-text">Ready to play the full game?</div>
+ <button id="playGameButton">Play Bracket Village</button>
+ </div>
+ `;
+
+ // Get the puzzle content container
+ const puzzleContent = this.root.querySelector('.puzzle-content');
+
+ // Create a wrapper for the completion message
+ const completionWrapper = document.createElement('div');
+ completionWrapper.className = 'tutorial-completion-wrapper';
+ completionWrapper.innerHTML = completionHTML;
+
+ // Insert the completion message at the top of puzzle content
+ if (puzzleContent) {
+ puzzleContent.insertBefore(completionWrapper, puzzleContent.firstChild);
+ }
+
+ // Add event listener to the play button
+ const playButton = this.root.querySelector('#playGameButton');
+ if (playButton) {
+ playButton.addEventListener('click', () => {
+ window.location.href = '/bv';
+ });
+ }
+ }
+
+ /**
+ * Updates the tutorial guidance based on the current state
+ */
+ updateTutorialGuidance() {
+ const tutorialGuidance = this.root.querySelector('.tutorial-guidance');
+ if (!tutorialGuidance) return;
+
+ // Find the appropriate tutorial step
+ let currentStep = this.tutorialSteps[0]; // Default to first step
+
+ for (let i = this.tutorialSteps.length - 1; i >= 0; i--) {
+ const step = this.tutorialSteps[i];
+
+ // If this step has a condition and it's satisfied, use this step
+ if (step.condition && step.condition(this.state.solvedExpressions)) {
+ currentStep = step;
+ break;
+ }
+ }
+
+ // Clone and replace the guidance element to restart animation
+ const newGuidance = tutorialGuidance.cloneNode(true);
+ const messageElement = document.createElement('div');
+ messageElement.className = 'tutorial-message';
+ messageElement.innerHTML = currentStep.message;
+
+ newGuidance.innerHTML = ''; // Clear existing content
+ newGuidance.appendChild(messageElement);
+ newGuidance.classList.add('emerge');
+
+ tutorialGuidance.parentNode.replaceChild(newGuidance, tutorialGuidance);
+
+ // Remove all highlight classes first
+ const allClues = this.root.querySelectorAll('.active-clue');
+ allClues.forEach(clue => {
+ clue.classList.remove('tutorial-highlight', 'tap-hint');
+ });
+
+ // Add highlighting based on the current step
+ if (currentStep.highlight === 'clue') {
+ // Highlight active clues more prominently using CSS class
+ const activeClues = this.root.querySelectorAll('.active-clue');
+ activeClues.forEach(clue => {
+ clue.classList.add('tutorial-highlight');
+ });
+ } else if (currentStep.highlight === 'hint') {
+ // Add a subtle animation to suggest tapping a clue using CSS class
+ const activeClues = this.root.querySelectorAll('.active-clue');
+ if (activeClues.length > 0) {
+ activeClues[0].classList.add('tap-hint');
+ }
+ }
+ }
+
+ /**
+ * Determines if a keystroke should be counted
+ * @param {KeyboardEvent} event - The keyboard event
+ * @returns {boolean} Whether the keystroke should be counted
+ */
+ isCountableKeystroke(event) {
+ // Only count single printable characters
+ return event.key.length === 1 &&
+ /^[a-zA-Z0-9!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/? ]$/.test(event.key);
+ }
+
+ /**
+ * Finds all active clues in the current puzzle state
+ * @param {string} puzzleText - Current puzzle text
+ * @returns {Array} Array of clue objects
+ */
+ findActiveClues(puzzleText) {
+ const activeClues = [];
+
+ function findClues(str, startOffset = 0) {
+ let i = 0;
+ while (i < str.length) {
+ if (str[i] === '[') {
+ const startIndex = i;
+ let bracketCount = 1;
+ let hasNestedBrackets = false;
+ i++;
+
+ let innerContent = '';
+ while (i < str.length && bracketCount > 0) {
+ if (str[i] === '[') {
+ bracketCount++;
+ hasNestedBrackets = true;
+ } else if (str[i] === ']') {
+ bracketCount--;
+ }
+ innerContent += str[i];
+ i++;
+ }
+
+ innerContent = innerContent.slice(0, -1);
+
+ if (!hasNestedBrackets) {
+ // Clean expression text of HTML markup
+ const cleanExpression = innerContent.replace(/<\/?[^>]+(>|$)/g, '');
+ activeClues.push({
+ start: startOffset + startIndex,
+ end: startOffset + i,
+ text: str.substring(startIndex, i),
+ expression: cleanExpression
+ });
+ }
+
+ if (hasNestedBrackets) {
+ findClues(innerContent, startOffset + startIndex + 1);
+ }
+ } else {
+ i++;
+ }
+ }
+ }
+
+ const cleanText = puzzleText.replace(/<\/?span[^>]*(>|$)/g, '');
+ findClues(cleanText);
+ return activeClues;
+ }
+
+ /**
+ * Finds available expressions that can be solved
+ * @param {string} text - Current puzzle text
+ * @returns {Array} Array of expression objects
+ */
+ findAvailableExpressions(text) {
+ const cleanText = text.replace(/<\/?span[^>]*(>|$)/g, '');
+ const results = [];
+ const regex = /\[([^\[\]]+?)\]/g;
+ const matchedPositions = new Set();
+ let match;
+
+ while ((match = regex.exec(cleanText)) !== null) {
+ const startIdx = match.index;
+ const endIdx = startIdx + match[0].length;
+
+ if (!matchedPositions.has(startIdx)) {
+ matchedPositions.add(startIdx);
+ results.push({
+ expression: match[1],
+ startIndex: startIdx,
+ endIndex: endIdx
+ });
+ }
+ }
+
+ return results;
+ }
+
+ /**
+ * Handles user input for solving expressions
+ * @param {string} input - User input string
+ */
+ handleInput(input) {
+ const normalizedInput = this.normalizeInput(input);
+ if (!normalizedInput) {
+ return;
+ }
+
+ const match = this.findMatchingExpression(normalizedInput);
+ if (!match) {
+ return;
+ }
+
+ this.solveExpression(match);
+ }
+
+ /**
+ * Normalizes and validates user input
+ * @param {string} input - Raw user input
+ * @returns {string|null} Normalized input or null if invalid
+ */
+ normalizeInput(input) {
+ if (!input?.trim()) {
+ return null;
+ }
+ return input.trim().toLowerCase();
+ }
+
+ /**
+ * Finds an unsolved expression matching the input
+ * @param {string} normalizedInput - Normalized user input
+ * @returns {Object|null} Matching expression info or null if not found
+ */
+ findMatchingExpression(normalizedInput) {
+ const cleanState = this.state.displayState.replace(/<\/?[^>]+(>|$)/g, '');
+ const availableExpressions = this.findAvailableExpressions(cleanState);
+
+ for (const {expression, startIndex, endIndex} of availableExpressions) {
+ const solution = this.PUZZLE_DATA.solutions[expression];
+
+ if (solution?.toLowerCase() === normalizedInput &&
+ !this.state.solvedExpressions.has(expression)) {
+ return {
+ expression,
+ solution,
+ startIndex,
+ endIndex
+ };
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Processes a correct solution and updates game state
+ * @param {Object} match - Expression match information
+ */
+ solveExpression(match) {
+ const { expression, solution, startIndex, endIndex } = match;
+
+ // Generate the new puzzle display state by replacing the solved expression
+ const cleanState = this.state.displayState.replace(/<\/?[^>]+(>|$)/g, '');
+ const escapedSolution = solution
+ .replace(/"/g, '&quot;')
+ .replace(/'/g, '&#39;');
+
+ const newDisplayState =
+ cleanState.slice(0, startIndex) +
+ `<mark class="solved">${escapedSolution}</mark>` +
+ cleanState.slice(endIndex);
+
+ // Add to the solved expressions set
+ const updatedExpressions = new Set([...this.state.solvedExpressions, expression]);
+ this.state.solvedOrder.push(expression);
+ // Update game state
+ this.state.displayState = newDisplayState;
+ this.state.solvedExpressions = updatedExpressions;
+
+ // Clear the input based on device type
+ if (this.isMobile) {
+ this.customInputValue = '';
+ this.updateCustomInputDisplay();
+ } else {
+ if (this.answerInput) {
+ this.answerInput.value = '';
+ }
+ }
+
+ // Re-render the puzzle display
+ this.render();
+
+ // Update tutorial guidance
+ this.updateTutorialGuidance();
+ }
+
+ /**
+ * Toggles hint mode for an expression
+ * @param {string} expression - The expression to toggle hints for
+ */
+ toggleHintMode(expression) {
+ // Early validation
+ if (!expression || this.state.megaPeekedClues.has(expression)) {
+ return;
+ }
+
+ // Handle mega peek (second click)
+ if (this.state.hintModeClues.has(expression)) {
+ this.handleMegaPeek(expression);
+ return;
+ }
+
+ // Handle first peek
+ this.handleFirstPeek(expression);
+ }
+
+ /**
+ * Handles the first peek at an expression (shows first letter)
+ * @param {string} expression - The expression to peek at
+ */
+ handleFirstPeek(expression) {
+ this.state.hintModeClues.add(expression);
+ this.state.peekedClues.add(expression);
+
+ this.render();
+ this.updateTutorialGuidance();
+ }
+
+ /**
+ * Handles the mega peek action (reveals answer)
+ * @param {string} expression - The expression to reveal
+ */
+ handleMegaPeek(expression) {
+ const solution = this.PUZZLE_DATA.solutions[expression];
+ if (!solution) {
+ console.error('No solution found for expression:', expression);
+ return;
+ }
+
+ const newDisplayState = this.processSolutionReveal(expression, solution);
+ if (!newDisplayState) {
+ console.error('Could not find expression in puzzle state');
+ return;
+ }
+
+ // Update state
+ this.state.displayState = newDisplayState;
+ this.state.megaPeekedClues.add(expression);
+ this.state.solvedExpressions.add(expression);
+ this.state.hintModeClues.delete(expression);
+
+ // Re-render
+ this.render();
+
+ // Update tutorial guidance
+ this.updateTutorialGuidance();
+ }
+
+ /**
+ * Processes a solution reveal for megapeek
+ * @param {Object} expression - Expression to reveal
+ * @param {string} solution - Solution text
+ * @returns {string} New display state
+ */
+ processSolutionReveal(expression, solution) {
+ // Clean the display state first
+ const cleanState = this.state.displayState.replace(/<\/?[^>]+(>|$)/g, '');
+ const availableExpressions = this.findAvailableExpressions(cleanState);
+
+ for (const {startIndex, endIndex} of availableExpressions) {
+ const currentExpression = cleanState.slice(startIndex + 1, endIndex - 1).trim();
+
+ if (expression === currentExpression) {
+ const escapedSolution = solution
+ .replace(/"/g, '&quot;')
+ .replace(/'/g, '&#39;');
+
+ const newDisplayState =
+ cleanState.slice(0, startIndex) +
+ `<mark class="solved">${escapedSolution}</mark>` +
+ cleanState.slice(endIndex);
+
+ return newDisplayState;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Checks if the puzzle is complete
+ * @returns {boolean} Whether the puzzle is complete
+ */
+ isPuzzleComplete() {
+ return !this.state.displayState.includes('[');
+ }
+ }
+
+ // Export the class for use in other files
+ if (typeof module !== 'undefined' && module.exports) {
+ module.exports = { BracketCityTutorial };
+ }