diff options
author | Nat Lasseter <user@4574.co.uk> | 2025-04-01 11:45:48 +0100 |
---|---|---|
committer | Nat Lasseter <user@4574.co.uk> | 2025-04-01 11:45:48 +0100 |
commit | 293df027348307de896232f1b867dd220ae8caa3 (patch) | |
tree | d63fedaadde32ef336f3004efb99c6dcf1f23585 /static/bv | |
parent | e26f3cdaf3919375b8ba825ee1a486d229f21def (diff) |
[bracket] rename
Diffstat (limited to 'static/bv')
-rw-r--r-- | static/bv/bc-favicon.png | bin | 0 -> 15538 bytes | |||
-rw-r--r-- | static/bv/bracket.css | 1286 | ||||
-rw-r--r-- | static/bv/bracket.js | 4071 | ||||
-rw-r--r-- | static/bv/index.html | 30 | ||||
-rw-r--r-- | static/bv/puzzleencoder.js | 83 | ||||
-rw-r--r-- | static/bv/tutorial/index.html | 22 | ||||
-rw-r--r-- | static/bv/tutorial/tutorial-init.js | 22 | ||||
-rw-r--r-- | static/bv/tutorial/tutorial.css | 945 | ||||
-rw-r--r-- | static/bv/tutorial/tutorial.js | 1011 |
9 files changed, 7470 insertions, 0 deletions
diff --git a/static/bv/bc-favicon.png b/static/bv/bc-favicon.png Binary files differnew file mode 100644 index 0000000..dc5136a --- /dev/null +++ b/static/bv/bc-favicon.png diff --git a/static/bv/bracket.css b/static/bv/bracket.css new file mode 100644 index 0000000..ab0285f --- /dev/null +++ b/static/bv/bracket.css @@ -0,0 +1,1286 @@ +/* =================== + Root Variables and Resets + =================== */ + :root { + --main-font: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; + --mono-font: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + --highlight-color: #fefcbf; + } + + *, *::before, *::after { + box-sizing: border-box; + } + + /* =================== + Container Structure + =================== */ + .puzzle-container { + width: 100%; + margin: 0 auto; + border: 1px solid #e2e8f0; + border-radius: 0.5rem; + background: white; + box-shadow: 0 1px 3px rgba(0,0,0,0.1); + padding: 0; + display: flex; + flex-direction: column; + } + + @media (min-width: 640px) { + .puzzle-container { + max-width: 42rem; + } + } + + /* =================== + Header Styles + =================== */ + /* Header Base Styles */ + .puzzle-header { + background: white; + border-bottom: 1px solid #e2e8f0; + box-sizing: border-box; + padding: 0.6rem; + position: relative; /* Desktop default */ + text-align: center; + z-index: 10; + } + + /* Mobile-specific header styles */ + @media (max-width: 640px) { + .puzzle-header { + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 1000; + padding: max(0.4rem, env(safe-area-inset-top)); + } + } + + /* Header title */ + .puzzle-header h1 { + margin: 0 0 0.5rem 0; + text-align: center; + font-size: clamp(2rem, 8vw, 3rem); + cursor: pointer; + font-family: var(--main-font); + font-variant: small-caps; + color: #1a202c; + font-weight: 600; + } + + @media (max-width: 640px) { + .puzzle-header h1 { + margin: 0; + font-size: clamp(1.8rem, 7vw, 2.8rem); + } + } + + /* Help button */ + .help-icon { + background: none; + border: 2px solid #333; + border-radius: 50%; + width: 2.4rem; + height: 2.4rem; + display: inline-flex; + align-items: center; + justify-content: center; + font-weight: bold; + font-size: 1.2rem; + color: #333; + } + + .puzzle-header .help-button { + position: absolute; + top: 10px; + right: 20px; + background: none; + border: 2px solid #333; + border-radius: 50%; + width: 2.4rem; + height: 2.4rem; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + font-weight: bold; + font-size: 1.2rem; + color: #333; + } + + @media (max-width: 640px) { + .puzzle-header .help-button { + right: 10px; + } + } + + /* Text bubble for help button */ +.help-button.new-player-highlight::after { + content: "try the tutorial!"; + position: absolute; + top: 42px; + right: -10px; + background-color: #333; + color: white; + padding: 6px 10px; + border-radius: 6px; + font-size: 14px; + font-weight: normal; + white-space: nowrap; + z-index: 10; + filter: drop-shadow(0 2px 3px rgba(0,0,0,0.2)); + /*animation: fade-in-out 3s infinite;*/ +} + +/* Triangle pointer for the bubble */ +.help-button.new-player-highlight::before { + content: ""; + position: absolute; + top: 35px; + right: 10px; + width: 0; + height: 0; + border-left: 8px solid transparent; + border-right: 8px solid transparent; + border-bottom: 8px solid #333; + z-index: 10; + /*animation: fade-in-out 3s infinite;*/ + +} + +/* Animation for the bubble to fade in and out */ +@keyframes fade-in-out { + 0%, 100% { opacity: 0.7; } + 50% { opacity: 1; } +} + +/* Keep the pulsing animation for the button itself */ +.help-button.new-player-highlight { + border-color: rgb(0, 0, 0); + color: rgb(0, 0, 0); + animation: pulse 1s infinite; +} + + /* Optional: Pulse animation to draw attention */ + @keyframes pulse { + 0% { transform: scale(1); } + 50% { transform: scale(1.10); } + 100% { transform: scale(1); } + } + + /* Help button */ + .puzzle-header .info-button { + position: absolute; + top: 10px; + left: 20px; + background: none; + border: 2px solid #333; + border-radius: 50%; + width: 2.4rem; + height: 2.4rem; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + font-weight: bold; + font-size: 1.2rem; + font-family: menlo; + color: #333; + } + + /* Style for the unseen info button */ + .info-button.unseen-info { + border-color: #000000; /* Red border */ + color: #ef4444; /* Red text */ + /*animation: pulse 1s infinite; Optional: add a subtle pulse animation */ + } + + /* Optional: Pulse animation to draw attention */ + @keyframes pulse { + 0% { transform: scale(1); } + 50% { transform: scale(1.10); } + 100% { transform: scale(1); } + } + + @media (max-width: 640px) { + .puzzle-header .info-button { + left: 10px; + } + } + + /* Navigation container */ + .puzzle-header .nav-container { + display: flex; + align-items: center; + justify-content: center; + gap: 1rem; + } + + /* Navigation buttons */ + .puzzle-header .nav-button { + padding: 4px 12px; + border: none; + border-radius: 4px; + background: none; + cursor: pointer; + font-weight: bold; + font-size: 1.5rem; + color: #333; + } + + .puzzle-header .nav-button:disabled { + opacity: 0.3; + cursor: default; + } + + /* Puzzle date */ + .puzzle-header .puzzle-date { + margin: 0; + font-size: 20px; + font-weight: 600; + font-family: var(--main-font); + font-variant: small-caps; + color: #1a202c; + } + + /* =================== + Main Content Area + =================== */ + /* New rules for loading state */ + + .loading { + text-align: center; + padding: 2rem; + } + + .puzzle-content { + padding: 1rem; + display: flex; + flex-direction: column; + gap: 0rem; + width: 100%; + } + + /* =================== + Puzzle Display + =================== */ + .puzzle-display { + width: 100%; + min-height: 40vh; + max-height: 60vh; + background: #f1f5f9; + border-radius: 0.5rem; + padding: 1.5rem; + font-family: var(--mono-font); + font-size: 24px; + line-height: 1.5; + overflow-y: auto; + word-break: keep-all; + overflow-wrap: break-word; + hyphens: none; + transition: all 0.3s ease; + } + + .puzzle-display.completed { + min-height: auto; + max-height: none; + height: auto; + } + + .blank-line { + display: inline-block; + border-bottom: .15rem solid currentColor; + line-height: 1; + position: relative; + top: .2rem; /* Move line down slightly */ + padding: 0 2px; + margin: 0 1px; + } + + @media (max-width: 640px) { + .blank-line { + border-bottom: .1rem solid currentColor; + } + } + + /* =================== + Active Clue & Mark Styles + =================== */ + .active-clue { + background-color: rgba(255, 255, 0, 0.2); + border-radius: 3px; + padding: 2px 4px; + margin: -2px -4px; + transition: background-color 0.3s ease; + cursor: pointer; + } + + .active-clue:hover { + background-color: rgba(255, 255, 0, 0.3); + } + + mark.solved { + background-color: rgba(0, 255, 0, 0.2); + border-radius: 3px; + padding: 2px 4px; + margin: -2px -4px; + } + + mark { + background-color: transparent; + color: inherit; + } + + /* =================== + Input Styles + =================== */ + .input-container { + width: 100%; + margin: 10px 0 0 0; + position: relative; + } + + .answer-input { + width: 100%; + padding: 16px; + height: 60px; + font-size: 16px !important; + border: 1px solid #e2e8f0; + border-radius: 6px; + margin-bottom: 0px; + box-sizing: border-box; + font-family: var(--mono-font); + } + + /* =================== + Message Styles + =================== */ + .message { + padding: 0.5rem; + margin: 0.5rem 0; + border-radius: 0.375rem; + text-align: center; + font-family: var(--mono-font); + display: none; + } + + .message.success { + background-color: #c6f6d5; + color: #1a202c; + overflow-wrap: anywhere; + font-size: 1.2rem; + display: block; + padding: 20px; + line-height: 2rem; + } + + + .message.success .completion-link { + color: #2563eb; + text-decoration: underline; + } + + .message.error { + background-color: #fee2e2; + color: #1a202c; + overflow-wrap: anywhere; + font-size: 1.2rem; + display: block; + padding: 20px; + line-height: 2rem; + } + + /* =================== + Completion Message + =================== */ + .completion-message { + margin: 0.5rem 0; + padding: 0rem; + border-radius: 0.375rem; + text-align: center; + font-family: var(--mono-font); + font-size: 1.2rem; + line-height: 1.5; + } + + /* =================== + Solved Expressions + =================== */ + .solved-expressions { + margin-top: 0rem; + } + + .solved-expressions h3 { + font-size: 1.2rem; + font-weight: 500; + margin-bottom: 0.75rem; + font-family: var(--main-font); + color: #1a202c; + } + + .expression-item { + background: #f1f5f9; + padding: 0.75rem; + border-radius: 0.375rem; + margin-bottom: 0.5rem; + font-size: 1.2rem; + font-family: var(--mono-font); + line-height: 1.5; + } + + .expression-item .solution { + font-weight: 600; + color: #1a202c; + } + + + /* =================== + Stats & Completion + =================== */ + .stats-content { + margin-top: 0rem; + } + + .stat-items { + margin: 0.5rem 0; + } + + .stat-item { + background: #f1f5f9; + padding: 0.75rem; + border-radius: 0.375rem; + margin-bottom: 0.5rem; + font-size: 1rem; + line-height: 1.5; + font-family: var(--main-font); + } + + /* =================== + Share Button Styles + =================== */ + .share-buttons { + margin-top: 1rem; + display: flex; + flex-direction: column; + gap: 0.5rem; + } + + .share-button { + width: 100%; + padding: 0.75rem; + font-size: 1rem; + font-family: var(--main-font); + background: #1a202c; + color: white; + border: none; + border-radius: 0.375rem; + cursor: pointer; + } + + .share-button.reset { + background-color: #ff4444; + } + + .share-button:hover { + background: #2d3748; + } + + .share-message { + font-family: var(--main-font); + color: #1a202c; + text-align: center; + padding: 0.5rem; + margin-top: 0.5rem; + background: var(--highlight-color); + border-radius: 0.375rem; + } + + /* =================== + Rank Display + =================== */ + .rank-display { + font-family: var(--mono-font); + position: relative; + overflow: hidden; + padding: 1rem; + text-align: center; + font-size: 1.2em; + font-weight: 500; + border-radius: 0.375rem; + margin: 0rem 0rem 1rem 0rem; + border: 1px solid rgba(255,255,255,0.4); + box-shadow: 0 2px 4px rgba(0,0,0,0.05); + cursor: pointer; + transition: transform 0.2s; + user-select: none; + } + + .rank-display:active { + transform: scale(0.95); + } + .rank-display .next-rank { + font-size: 0.8em; + font-weight: normal; + } + + .rank-display .share-hint { + font-size: 0.7em; + margin-top: 0.5em; + color: #666; + } + + /* Rank gradients */ + .rank-display[data-rank="Tourist"] { background: linear-gradient(135deg, #f0fdf4 0%, #6ee7b7 100%); } + .rank-display[data-rank="Commuter"] { background: linear-gradient(135deg, #fff7ed 0%, #fbd38d 100%); } + .rank-display[data-rank="Resident"] { background: linear-gradient(135deg, #f8fafc 0%, #cbd5e1 100%); } + .rank-display[data-rank="Council Member"] { background: linear-gradient(135deg, #fafaf9 0%, #d6d3d1 100%); } + .rank-display[data-rank="Chief of Police"] { background: linear-gradient(135deg, #f0f9ff 0%, #93c5fd 100%); } + .rank-display[data-rank="Mayor"] { background: linear-gradient(135deg, #fff1f2 0%, #fda4af 100%); } + .rank-display[data-rank="Power Broker"] { background: linear-gradient(135deg, #fefce8 0%, #92400e 100%); } + .rank-display[data-rank="Kingmaker"] { background: linear-gradient(135deg, #fffbeb 0%, #fcd34d 100%); } + .rank-display[data-rank="Puppet Master"] { background: linear-gradient(135deg, #faf5ff 0%, #d8b4fe 100%); } + + /* =================== + Shimmer Animation + =================== */ + @keyframes shimmer { + 0% { transform: translateX(-150%) rotate(45deg); } + 100% { transform: translateX(150%) rotate(45deg); } + } + + .rank-display::after { + content: ''; + position: absolute; + top: -50%; + left: -100%; + width: 300%; + height: 200%; + background: linear-gradient( + to bottom right, + rgba(255,255,255,0) 0%, + rgba(255,255,255,0.3) 50%, + rgba(255,255,255,0) 100% + ); + transform: rotate(45deg); + animation: shimmer 4s infinite linear; + pointer-events: none; + } + + /* =================== + Mobile Styles + =================== */ + @media (max-width: 640px) { + body { + margin: 0; + padding: 0; + height: 100vh; + font-size: 14px; + overflow: hidden; + } + + .message.success { + background-color: #c6f6d5; + color: #1a202c; + overflow-wrap: anywhere; + font-size: 1rem; + display: block; + padding: 10px; + line-height: 1.4rem; + margin-top: 0.2rem; + } + + .message.error { + background-color: #fee2e2; + color: #1a202c; + overflow-wrap: anywhere; + font-size: 1rem; + display: block; + padding: 10px; + line-height: 1.4rem; + margin-top: 0.2rem; + } + + + + /* Mobile container setup */ + .puzzle-container { + height: 100vh; + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + display: flex; + flex-direction: column; + } + + /* Mobile content area */ + .puzzle-content { + position: absolute; + top: 85px; + left: 0; + right: 0; + bottom: 0; + overflow-y: auto; + -webkit-overflow-scrolling: touch; + padding: 0; + gap: 0rem; + } + + @font-face { + font-family: 'CustomMono'; + /* Use ui-monospace for every character except underscore */ + src: local("ui-monospace"); + /* All Unicode except underscore (U+005F) */ + unicode-range: U+0000-005E, U+0060-10FFFF; + } + + @font-face { + font-family: 'CustomMono'; + /* Use Menlo for underscore only */ + src: local("Menlo"); + unicode-range: U+005F; + } + + .puzzle-display { + height: auto; + padding: 0.5rem 1rem 1rem 1rem; + margin: 0; + font-size: 1.2rem; + background: #f1f5f9; + padding-bottom: 200px; + font-family: ui-monospace, "SF Mono", "Roboto Mono", "Menlo", "Consolas", "Liberation Mono", monospace; + } + + .puzzle-display.completed { + padding-bottom: 1rem !important; + } + + + .rank-display { + margin: 0rem 0.5rem 1rem 0.5rem; + } + + + /* Mobile input container */ + .input-container { + position: fixed; + bottom: 0; + left: 0; + right: 0; + background: white; + z-index: 10; + width: 100%; + border-top: 1px solid #e2e8f0; + box-shadow: 0 -2px 6px rgba(0,0,0,0.1); + padding: 0.5rem calc(0.5rem + env(safe-area-inset-left)) calc(0.5rem + env(safe-area-inset-bottom)) calc(0.5rem + env(safe-area-inset-right)); + box-sizing: border-box; + } + + .custom-input { + border: 1px solid #e2e8f0; + padding: 0.63rem; + border-radius: 6px; + margin-bottom: 0.125rem; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 1.2rem; + min-height: 2.4rem; + background: white; + width: 100%; + color: #000; + } + + .placeholder { + color: #9ca3af; + } + + /* Mobile keyboard */ + .custom-keyboard { + width: 100%; + padding: .25rem 0 0 0; + box-sizing: border-box; + background-color: white; + touch-action: none; + } + + .keyboard-row { + display: grid; + grid-template-columns: repeat(10, 1fr); + gap: 0.02rem; + margin-bottom: 0.125rem; + justify-content: center; + width: 100%; + } + + .keyboard-key { + height: 3.3rem; + min-height: 2.2rem; + display: flex; + align-items: center; + justify-content: center; + padding: 0.2rem; + margin: 0.1rem 0.15rem 0.1rem 0.15rem; + cursor: pointer; + color: black; + background-color:rgb(233, 233, 233); + box-sizing: border-box; + touch-action: manipulation; + text-align: center; + font-size: 1.125rem; + font-weight: 600; + border: 1px solid rgb(200, 200, 200); + user-select: none; + -webkit-tap-highlight-color: transparent; + -webkit-appearance: none !important; + appearance: none !important; + border-radius: 4px !important; + outline: none !important; + -webkit-tap-highlight-color: transparent !important; + transition: background-color 0.1s ease-out, + transform 0.1s ease-out; + -webkit-user-select: none; + user-select: none; + -webkit-transform: translateZ(0); + transform: translateZ(0); + } + + .keyboard-key:active { + background-color: #94a3b8; + transform: translateY(2px); + } + + .stat-item { + background: #f1f5f9; + padding: 0.75rem; + border-radius: 0.375rem; + margin-bottom: 0.5rem; + font-size: 1rem; + line-height: 1.5; + } + + /* Hide desktop elements */ + .bottom-controls, + .reset-button, + .solved-expressions, + .keystroke-stats, + .share-button.copy { + display: none; + } + + /* Mobile completion state */ + .puzzle-container.completed .puzzle-content { + position: static; + height: auto; + overflow: visible; + margin-top: 85px; + padding: 1rem; + } + + .puzzle-container.completed { + position: static; + height: auto; + min-height: 100vh; + overflow-y: auto; + } + + .puzzle-display.completed .input-container { + display: none; + } + + .puzzle-container.completed .solved-expressions, + .puzzle-container.completed .keystroke-stats { + display: block; + margin: 1rem; + } + + .keystroke-stats { + margin-top: 0rem; + } + } + + /* =================== + iOS-specific Styles + =================== */ + @supports (-webkit-touch-callout: none) { + .keyboard-key:active { + background-color: #94a3b8 !important; + transform: translateY(2px) !important; + } + } + + /* =================== + Score Bar + =================== */ + + .score-bar { + margin: 1.5rem 0; + text-align: center; + font-size: 1.1rem; + font-family: var(--mono-font), monospace; + border: 2px dashed #aaa; + padding: 1rem; + border-radius: 0.5rem; + background-color: rgba(255, 255, 255, 0.5); + } + + .score-value { + font-weight: bold; + margin-bottom: 0rem; + } + + .progress-bar { + font-size: 1.5rem; + letter-spacing: 0.1rem; + line-height: 1.5; + } + + /* Mobile adjustments */ + @media (max-width: 640px) { + .score-bar { + margin: 1rem 0; + font-size: 1rem; + padding: 0.75rem; + } + + .progress-bar { + font-size: 1.2rem; + } + } + + /* Submit button and input wrapper */ + .input-submit-wrapper { + display: flex; + gap: 0.5rem; + width: 100%; + } + + .submit-answer { + padding: 0 1.5rem; + height: 60px; + background-color: #2563eb; + color: white; + border: none; + border-radius: 0.375rem; + cursor: pointer; + font-weight: 500; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + white-space: nowrap; + font-size: 1rem; + } + + .submit-answer:hover { + background-color: #1d4ed8; + } + + .submit-answer:active { + background-color: #1e40af; + transform: scale(0.98); + } + + .mobile-submit-button { + background-color: #2563eb !important; + color: white !important; + font-size: 1rem !important; + font-weight: 500 !important; + } + +/* Date Picker Styles */ +.puzzle-date { + cursor: pointer; + position: relative; + display: inline-flex; + align-items: center; + transition: color 0.2s ease; + padding: 4px; /* Larger tap target */ + border-radius: 4px; + user-select: none; + -webkit-tap-highlight-color: rgba(0,0,0,0); /* Remove default mobile tap highlight */ + touch-action: manipulation; /* Optimize for touch */ +} + +.puzzle-date:hover { + color: #4299e1; + background-color: rgba(66, 153, 225, 0.1); +} + +.puzzle-date:active { + transform: scale(0.98); +} + +.puzzle-date::after { + content: "\23F7"; /* dropdown arrow maybe */ + display: inline-block; + margin-left: 0.5rem; + font-size: 0.8em; + opacity: 0.7; +} + +/* Visible hint on mobile */ +@media (max-width: 640px) { + .puzzle-date { + padding: 5px 8px; + border: none !important; /* Explicitly remove any border */ + } + + .puzzle-date::after { + font-size: 1em; + margin-left: 8px; + } +} + +.date-picker-container { + font-family: var(--main-font); + border-radius: 0.5rem; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15); + border: 1px solid rgba(0, 0, 0, 0.1); + background-color: white; + animation: fadeIn 0.2s ease; + box-sizing: border-box; + overflow: hidden; /* Ensure nothing bleeds outside the container */ + padding: 1rem !important; /* Force padding */ +} + +@keyframes fadeIn { + from { opacity: 0; transform: translateY(-10px) translateX(-50%); } + to { opacity: 1; transform: translateY(0) translateX(-50%); } +} + +.month-nav { + width: 30px; + height: 30px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + transition: background-color 0.2s; +} + +.month-nav:hover { + background-color: #f0f0f0; +} + +.weekdays { + font-size: 0.85rem; + color: #4a5568; + padding-bottom: 0.5rem; +} + +.calendar-grid { + font-size: 0.95rem; +} + +.date-cell { + transition: transform 0.1s ease, box-shadow 0.1s ease; + user-select: none; + aspect-ratio: 1; + display: flex; + align-items: center; + justify-content: center; +} + +.date-cell.has-puzzle:hover:not([disabled="true"]) { + transform: translateY(-2px); + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1); +} + +.date-cell.has-puzzle:active:not([disabled="true"]) { + transform: translateY(0); +} + +.date-cell.has-puzzle[data-status="complete"] { + position: relative; +} + +.date-cell.has-puzzle[data-status="complete"]::after { + content: "\2713"; + position: absolute; + top: 0; + right: 2px; + font-size: 8px; + color: #22543d; +} + +.date-cell.has-puzzle[data-status="started"] { + position: relative; +} + +.date-cell.has-puzzle[data-status="started"]::after { + content: "\2022\2022\2022"; + position: absolute; + bottom: 1px; + left: 50%; + transform: translateX(-50%); + font-size: 8px; + line-height: 1; + color: #744210; +} + +.empty-day { + aspect-ratio: 1; +} + +/* Mobile styles */ +/* Fix for the date picker right column padding issue on mobile */ + +/* Balanced fix for date picker columns on mobile */ + +/* Robust fix for date picker edge issues on mobile */ + +@media (max-width: 640px) { + /* Force container to be smaller than viewport width and centered */ + .date-picker-container { + width: 85vw !important; /* Reduced from 90vw to ensure space */ + max-width: 300px !important; /* Smaller max-width */ + left: 50% !important; + transform: translateX(-50%) !important; + margin-left: 0 !important; + margin-right: 0 !important; + padding: 0.75rem !important; /* Slightly reduced padding */ + box-sizing: border-box !important; + border-radius: 0.5rem !important; + overflow: hidden !important; /* Force content to stay within bounds */ + } + + /* Force grid to be contained */ + .calendar-grid { + display: grid !important; + grid-template-columns: repeat(7, 1fr) !important; + gap: 3px !important; /* Reduced gap */ + width: 100% !important; + max-width: 100% !important; /* Ensure it can't exceed container */ + overflow: visible !important; /* Allow cell content to be visible */ + padding: 0 !important; + margin: 0 !important; + } + + /* Ensure weekday headers don't overflow */ + .weekdays { + display: grid !important; + grid-template-columns: repeat(7, 1fr) !important; + gap: 3px !important; + width: 100% !important; + max-width: 100% !important; + padding: 0 !important; + margin: 0 0 8px 0 !important; + } + + /* Make date cells slightly smaller */ + .date-cell { + width: 100% !important; + height: auto !important; + min-height: 30px !important; + display: flex !important; + align-items: center !important; + justify-content: center !important; + padding: 0.2rem 0 !important; + font-size: 0.85rem !important; /* Slightly smaller text */ + margin: 0 !important; + box-sizing: border-box !important; + } + + /* Explicitly set column sizes */ + .calendar-grid > div, .weekdays > div { + width: 100% !important; + box-sizing: border-box !important; + } + + /* Additional safeguard for month selector */ + .date-picker-header { + padding: 0 !important; + margin-bottom: 0.75rem !important; + } +} + +/* iOS Toggle Switch */ +.toggle-container { + margin: 1em 0; + display: flex; + justify-content: space-between; + align-items: center; +} + +.toggle-labels { + display: flex; + flex-direction: column; +} + +.toggle-title { + font-weight: 500; + margin-bottom: 0.3em; +} + +.toggle-description { + font-size: 0.8em; + color: #666; + line-height: 1.2; +} + +.toggle-switch { + position: relative; + display: inline-block; + width: 50px; + height: 28px; + flex-shrink: 0; + margin-left: 12px; +} + +.toggle-switch input { + opacity: 0; + width: 0; + height: 0; +} + +.toggle-slider { + position: absolute; + cursor: pointer; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: #ccc; + transition: .4s; + border-radius: 34px; +} + +.toggle-slider:before { + position: absolute; + content: ""; + height: 24px; + width: 24px; + left: 2px; + bottom: 2px; + background-color: white; + transition: .3s; + border-radius: 50%; + box-shadow: 0 1px 3px rgba(0,0,0,0.2); +} + +input:checked + .toggle-slider { + background-color: #2563eb; +} + +input:checked + .toggle-slider:before { + transform: translateX(22px); +} + +/* Mode labels */ +.mode-indicator { + margin-top: 0.8em; + font-size: 0.9em; + color: #555; + text-align: center; +} + +.mode-name { + font-weight: 600; + color: #2563eb; +} + +/* Modal Styles */ +.announcement-modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.5); + z-index: 2000; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + transition: opacity 0.3s ease; + pointer-events: none; +} + +.announcement-modal-overlay.visible { + opacity: 1; + pointer-events: auto; +} + +.announcement-modal { + background-color: white; + border-radius: 0.5rem; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15); + width: 90%; + max-width: 500px; + max-height: 90vh; + overflow-y: auto; + padding: 1.5rem; + position: relative; + transform: translateY(-20px); + transition: transform 0.3s ease; +} + +.announcement-modal-overlay.visible .announcement-modal { + transform: translateY(0); +} + +.announcement-modal-title { + font-size: 1.5rem; + font-weight: 600; + margin-bottom: 1rem; + color: #1a202c; + text-align: center; + font-family: var(--main-font); +} + +.announcement-modal-content { + font-size: 1rem; + line-height: 1.5; + color: #4a5568; + margin-bottom: 1.5rem; + font-family: var(--main-font); +} + +.announcement-modal-button { + display: block; + width: 100%; + padding: 0.75rem; + background-color: #2563eb; + color: white; + border: none; + border-radius: 0.375rem; + font-size: 1rem; + font-weight: 500; + cursor: pointer; + text-align: center; + transition: background-color 0.2s; + font-family: var(--main-font); +} + +.announcement-modal-button:hover { + background-color: #1d4ed8; +} + +.announcement-modal-button:active { + background-color: #1e40af; + transform: scale(0.98); +} + +/* Add more space between list items */ +.announcement-modal-content li { + margin-bottom: 0.75rem; +} + +/* Remove extra margin from the last list item */ +.announcement-modal-content li:last-child { + margin-bottom: 0; +} + +@media (max-width: 640px) { + .announcement-modal { + width: 85%; + padding: 1rem; + } + + .announcement-modal-title { + font-size: 1.25rem; + } + + .announcement-modal-content { + font-size: 0.95rem; + } +} diff --git a/static/bv/bracket.js b/static/bv/bracket.js new file mode 100644 index 0000000..0d42747 --- /dev/null +++ b/static/bv/bracket.js @@ -0,0 +1,4071 @@ +class BracketCityRank { + /** + * @typedef {Object} RankConfig + * @property {number} peekPenalty - Points deducted per peek + * @property {number} megaPeekPenalty - Points deducted per mega peek + * @property {number} wrongGuessPenalty - Points deducted per wrong guess + * @property {Object.<string, number>} rankThresholds - Score thresholds for each rank + */ + + /** + * @param {Partial<RankConfig>} config - Optional configuration overrides + */ + constructor(config = {}) { + // Define rank emojis with consistent Unicode escapes + this.rankEmojis = { + 'Tourist': '\u{1F4F8}', // Camera with flash + 'Commuter': '\u{1F682}', // Steam locomotive + 'Resident': '\u{1F3E0}', // House + 'Council Member': '\u{1F3DB}', // Classical building + 'Chief of Police': '\u{1F46E}', // Police officer + 'Mayor': '\u{1F396}', // Military medal + 'Power Broker': '\u{1F4BC}', // Briefcase + 'Kingmaker': '\u{1F451}', // Crown + 'Puppet Master': '\u{1F52E}' // Crystal ball + }; + + // Default configuration + const defaultConfig = { + peekPenalty: 5, + megaPeekPenalty: 15, + wrongGuessPenalty: 2, // For regular mode + rankThresholds: { + 'Tourist': 0, + 'Commuter': 11, + 'Resident': 21, + 'Council Member': 31, + 'Chief of Police': 51, + 'Mayor': 68, + 'Power Broker': 79, + 'Kingmaker': 91, + 'Puppet Master': 100 + }, + regularRankThresholds: { + 'Tourist': 0, + 'Commuter': 10, + 'Resident': 20, + 'Council Member': 45, + 'Chief of Police': 65, + 'Mayor': 80, + 'Power Broker': 90, + 'Kingmaker': 100, + 'Puppet Master': 100 + } + }; + + // Merge provided config with defaults + this.config = { + ...defaultConfig, + ...config, + // Ensure rankThresholds is fully merged + rankThresholds: { + ...defaultConfig.rankThresholds, + ...(config.rankThresholds || {}) + }, + regularRankThresholds: { + ...defaultConfig.regularRankThresholds, + ...(config.regularRankThresholds || {}) + } + }; + } + + /** + * Modified calculateScore method to account for different scoring systems + * @param {number} efficiency - Base efficiency score (0-100) + * @param {number} peekCount - Number of peeks used + * @param {number} megaPeekCount - Number of mega peeks used + * @param {number} wrongGuessCount - Number of wrong guesses + * @returns {Object} Detailed score breakdown + */ + calculateScore(efficiency, peekCount = 0, megaPeekCount = 0, wrongGuessCount = 0) { + // Always ensure inputs are numbers and non-negative + const safepeekCount = Math.max(0, Number(peekCount) || 0); + const safeMegaPeekCount = Math.max(0, Number(megaPeekCount) || 0); + const safeWrongGuessCount = Math.max(0, Number(wrongGuessCount) || 0); + + // Calculate penalties + const peekPenalty = safepeekCount * this.config.peekPenalty; + const megaPeekPenalty = safeMegaPeekCount * this.config.megaPeekPenalty; + + // Different base score calculation based on mode + let baseScore, wrongGuessPenalty, totalPenalty, finalScore; + + if (this.inputMode === 'classic') { + // Classic mode: score based on keystroke efficiency + baseScore = Math.max(0, Number(efficiency) || 0); + wrongGuessPenalty = 0; // No wrong guess penalty in classic mode + totalPenalty = peekPenalty + megaPeekPenalty; + } else { + // Submit mode: score starts at 100, penalty for wrong guesses + baseScore = 100; // Start at max + wrongGuessPenalty = safeWrongGuessCount * this.config.wrongGuessPenalty; + totalPenalty = peekPenalty + megaPeekPenalty + wrongGuessPenalty; + } + + // Calculate final score (capped at 100) + finalScore = Math.min(100, Math.max(0, baseScore - totalPenalty)); + + return { + baseScore, + peekPenalty, + megaPeekPenalty, + wrongGuessPenalty, + totalPenalty, + finalScore + }; + } + + /** + * Generates detailed stats including rank and scoring information + * Accounts for different modes + * @param {number} efficiency - Base efficiency score + * @param {number} peekCount - Number of peeks used + * @param {number} megaPeekCount - Number of mega peeks used + * @param {number} wrongGuessCount - Number of wrong guesses + * @returns {Object} Detailed stats object + */ + getDetailedStats(efficiency, peekCount = 0, megaPeekCount = 0, wrongGuessCount = 0) { + const scoreDetails = this.calculateScore(efficiency, peekCount, megaPeekCount, wrongGuessCount); + const rank = this.calculateRank(efficiency, peekCount, megaPeekCount, wrongGuessCount); + + // Calculate points needed for next rank + const thresholds = this.inputMode === 'classic' ? + this.config.rankThresholds : + this.config.regularRankThresholds; + + const ranks = Object.entries(thresholds) + .sort((a, b) => a[1] - b[1]); // Sort ascending + + const currentRankIndex = ranks.findIndex(([r]) => r === rank); + const nextRank = currentRankIndex < ranks.length - 1 ? + ranks[currentRankIndex + 1] : null; + + const pointsToNextRank = nextRank ? + Math.max(0, nextRank[1] - scoreDetails.finalScore) : 0; + + return { + rank, + rankEmoji: this.rankEmojis[rank] || this.rankEmojis['Tourist'], + ...scoreDetails, + nextRankName: nextRank ? nextRank[0] : null, + pointsToNextRank: Math.ceil(pointsToNextRank), // Round up for user-friendly display + config: this.config + }; + } + + /** + * Determines rank based on final score + * @param {number} efficiency - Base efficiency score + * @param {number} peekCount - Number of peeks used + * @param {number} megaPeekCount - Number of mega peeks used + * @param {number} wrongGuessCount - Number of wrong guesses + * @returns {string} Rank title + */ + calculateRank(efficiency, peekCount = 0, megaPeekCount = 0, wrongGuessCount = 0) { + const { finalScore } = this.calculateScore(efficiency, peekCount, megaPeekCount, wrongGuessCount); + + // Only keep the Puppet Master special case + // Puppet Master: 100% keystroke-efficient (no excess keystrokes) AND no peeks/wrong guesses + const isPerfect = efficiency === 100; + const isNoHelp = peekCount === 0 && megaPeekCount === 0 && wrongGuessCount === 0; + + if (isPerfect && isNoHelp) { + return 'Puppet Master'; + } + + // For all other cases, follow normal threshold logic + const thresholds = this.inputMode === 'classic' ? + this.config.rankThresholds : + this.config.regularRankThresholds; + + const ranks = Object.entries(thresholds) + .sort((a, b) => b[1] - a[1]); + + // Find highest rank threshold that score exceeds + const rank = ranks.find(([_, threshold]) => finalScore >= threshold); + return rank ? rank[0] : 'Tourist'; + } +} + + /** + * @typedef {Object} GameState + * @property {string} displayState - Current puzzle display with HTML markup + * @property {Set<string>} solvedExpressions - Set of solved expression strings + * @property {string} message - Current user message + * @property {number} totalKeystrokes - Total keystrokes used + * @property {number} minimumKeystrokes - Minimum keystrokes needed + * @property {Array<{start: number, end: number, text: string, expression: string}>} activeClues + * @property {Set<string>} hintModeClues - Clues that have been peeked + * @property {Set<string>} peekedClues - Tracking for peek penalties + * @property {Set<string>} megaPeekedClues - Clues that were fully revealed + */ + + + class BracketPuzzle { + + /**************************** + Core initilization methods + ****************************/ + + /** + * Constructor and Initialization + */ + + constructor(rootElement) { + this.root = rootElement; + this.API_ENDPOINT = 'https://user.4574.co.uk/bracket-api'; + this.PUZZLE_DATA = null; + + this.MODE_STORAGE_KEY = 'bracketCityInputMode'; + this.inputMode = this.getInputMode(); + + // Detect device type + this.isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent); + + // Add new property specifically for input method decision + this.useCustomKeyboard = this.isMobile && !this.isTablet(); + + // Track keyboard layout + this.isAltKeyboard = false; + + // Initialize user ID + this.userId = this.initializeUserId(); + + // Initialize empty state immediately + this.state = { + displayState: '', + solvedExpressions: new Set(), + message: '', + totalKeystrokes: 0, + minimumKeystrokes: 0, + activeClues: [], + hintModeClues: new Set(), + peekedClues: new Set(), + megaPeekedClues: new Set(), + wrongGuesses: 0 + }; + + // Initialize rank calculator + this.rankCalculator = new BracketCityRank(); + this.rankCalculator.inputMode = this.inputMode; + + // Start initialization + this.initializeGame(); + } + + /** + * Initializes the game + */ + async initializeGame() { + try { + console.log('Starting game initialization'); + + // Show loading state immediately + this.root.innerHTML = this.generateLoadingHTML(); + + // Get initial puzzle date, checking encoded puzzles first + const urlParams = new URLSearchParams(window.location.search); + const encoded = urlParams.get('d'); + + if (encoded) { + const decoded = PuzzleEncoder.decodePuzzle(encoded); + if (decoded) { + this.currentPuzzleDate = decoded.puzzleDate; + } else { + this.currentPuzzleDate = this.getPuzzleDateFromURL(); + } + } else { + this.currentPuzzleDate = this.getPuzzleDateFromURL(); + } + + console.log('Current puzzle date set to:', this.currentPuzzleDate); + + // Load saved input mode preference + this.inputMode = this.getInputMode(); + + // Update rankCalculator with the input mode + this.rankCalculator = new BracketCityRank(); + this.rankCalculator.inputMode = this.inputMode; + + // Start showing basic UI elements + this.root.innerHTML = this.generateInitialHTML(); + this.setupInputHandlers(); + + // Load puzzle data + console.log('Loading puzzle data'); + await this.initializePuzzleData(); + + if (!this.PUZZLE_DATA) { + throw new Error('Failed to initialize puzzle data'); + } + + // Load or initialize game state + console.log('Loading saved state'); + const savedState = await this.loadSavedState(this.currentPuzzleDate); + console.log('Saved state loaded:', savedState ? 'found' : 'not found'); + + // Initialize game state + if (savedState && savedState.initialPuzzle === this.PUZZLE_DATA.initialPuzzle) { + console.log('Using saved state'); + this.updateGameState({ + displayState: savedState.displayState, + solvedExpressions: new Set(savedState.solvedExpressions), + message: savedState.message || '', + totalKeystrokes: savedState.totalKeystrokes, + minimumKeystrokes: savedState.minimumKeystrokes, + activeClues: this.findActiveClues(savedState.displayState), + hintModeClues: new Set(savedState.hintModeClues || []), + peekedClues: new Set(savedState.peekedClues || []), + megaPeekedClues: new Set(savedState.megaPeekedClues || []), + wrongGuesses: savedState.wrongGuesses || 0 + }); + } else { + console.log('Initializing fresh state'); + this.updateGameState({ + displayState: this.PUZZLE_DATA.initialPuzzle, + solvedExpressions: new Set(), + message: '', + totalKeystrokes: 0, + minimumKeystrokes: this.calculateMinimumKeystrokes(), + activeClues: this.findActiveClues(this.PUZZLE_DATA.initialPuzzle), + hintModeClues: new Set(), + peekedClues: new Set(), + megaPeekedClues: new Set(), + wrongGuesses: 0 + }); + } + + this.setupDatePicker(); + + // Update URL to match current puzzle + if (!encoded) { // Only update URL if not an encoded puzzle + this.updateURL(this.currentPuzzleDate); + } + + // Do the initial render + console.log('Performing initial render'); + this.render(); + + // Load non-critical features + requestAnimationFrame(() => { + console.log('Setting up auxiliary features'); + this.setupShareButtons(); + this.setupPuzzleDisplay(); + + this.checkAdjacentPuzzles(this.currentPuzzleDate).then(() => { + this.setupNavigationHandlers(); + console.log('Navigation setup complete'); + }); + }); + // Show important announcement after UI is fully rendered + // Change the id parameter for each new announcement to ensure it shows + this.showImportantAnnouncement({ + title: '\u{1F6A8} Welcome to [Bracket Village] \u{1F6A8}', + message: ` + <ul style="text-align: left; margin: 1rem 0; padding-left: 1.5rem;"> + <li>Click <span class='help-icon'>?</span> to access the <mark style="background-color:rgba(255, 255, 0, 0.2);">tutorial</mark></li> + <li>Click <span class='help-icon'>!</span> to access <mark style="background-color:rgba(255, 255, 0, 0.2);">news and settings</mark></li> + <li>Click the date header to access the <mark style="background-color:rgba(255, 255, 0, 0.2);">date picker</mark> and browse the archive</li> + </ul> + <p><strong>Thank you for playing Bracket Village!</strong></p> + `, + buttonText: 'ok great thanks', + id: 'update-2025-3-11' // Change this ID for a new announcement + }); + + + } catch (error) { + console.error('Failed to initialize game:', error); + this.showErrorMessage('Failed to load the game. Please refresh the page to try again.'); + } + } + + /** + * Initializes or retrieves the user ID + * @returns {string} User ID + */ + initializeUserId() { + const storageKey = 'bracketCityUserId'; + let userId = localStorage.getItem(storageKey); + + if (!userId) { + userId = crypto.randomUUID(); + localStorage.setItem(storageKey, userId); + } + + return userId; + } + + async initializePuzzleData() { + const urlParams = new URLSearchParams(window.location.search); + const encoded = urlParams.get('d'); + + if (encoded) { + // Try PuzzleEncoder format first + const decodedPuzzle = PuzzleEncoder.decodePuzzle(encoded); + if (decodedPuzzle) { + this.PUZZLE_DATA = decodedPuzzle; + } else { + // Try direct base64 JSON format + try { + const jsonStr = atob(encoded); + const puzzleData = JSON.parse(jsonStr); + + // Validate required fields + if (puzzleData.initialPuzzle && puzzleData.solutions) { + this.PUZZLE_DATA = { + initialPuzzle: puzzleData.initialPuzzle, + solutions: puzzleData.solutions + }; + } else { + console.error('Invalid puzzle data structure in URL'); + await this.fetchPuzzleForDate(this.currentPuzzleDate); + } + } catch (e) { + console.error('Failed to decode puzzle data:', e); + await this.fetchPuzzleForDate(this.currentPuzzleDate); + } + } + } else { + // No encoded puzzle, fetch for specific date or today + await this.fetchPuzzleForDate(this.currentPuzzleDate); + } + + if (!this.PUZZLE_DATA) { + throw new Error('Failed to initialize puzzle data'); + } + } + + /** + * Checks if running in development environment + */ + isDevelopmentEnvironment() { + const hostname = window.location.hostname; + return hostname === 'localhost' || + hostname === '127.0.0.1' || + hostname === 'staging.bracket.city' || + hostname === 'dev.bracket.city'; + } + + + /** + * Gets the current input mode from localStorage or defaults to 'classic' + * @returns {string} The input mode ('classic' or 'submit') + */ + getInputMode() { + return localStorage.getItem(this.MODE_STORAGE_KEY) || 'submit'; + } + + /** + * Determines if a puzzle has been meaningfully started based on consistent criteria + * @param {Object} puzzleData - The puzzle state to check + * @returns {boolean} Whether the puzzle is considered started + */ + isPuzzleStarted(puzzleData = this.state) { + // If no data provided or using current state, check directly + if (puzzleData === this.state) { + return (this.state.totalKeystrokes > 5) || + (this.state.peekedClues && this.state.peekedClues.size > 0) || + (this.state.megaPeekedClues && this.state.megaPeekedClues.size > 0) || + (this.state.wrongGuesses > 0) || + (this.state.solvedExpressions && this.state.solvedExpressions.size > 0); + } + + // For saved state data from localStorage (which has arrays instead of Sets) + return (puzzleData.totalKeystrokes > 5) || + (puzzleData.peekedClues && puzzleData.peekedClues.length > 0) || + (puzzleData.megaPeekedClues && puzzleData.megaPeekedClues.length > 0) || + (puzzleData.wrongGuesses > 0) || + (puzzleData.solvedExpressions && puzzleData.solvedExpressions.length > 0); + } + + /** + * Sets the input mode and updates the UI + * @param {string} mode - The input mode to set ('classic' or 'submit') + */ + setInputMode(mode) { + if (mode !== 'classic' && mode !== 'submit') { + console.error('Invalid input mode:', mode); + return; + } + + // Check if the puzzle has been started + const hasPuzzleStarted = this.isPuzzleStarted(); + + // Only allow mode changes if the puzzle hasn't been started + if (hasPuzzleStarted) { + // Don't show a message - the disabled toggle with explanatory text is sufficient + console.log('Mode change prevented: puzzle already started'); + return; + } + + this.inputMode = mode; + localStorage.setItem(this.MODE_STORAGE_KEY, mode); + + // Update rankCalculator's input mode + this.rankCalculator.inputMode = mode; + + // Update the UI to reflect the new mode + this.updateInputModeUI(); + + // If help/info is currently visible, ensure inputs stay disabled + if (this.state.helpVisible) { + this.disableInputsForHelp(); + } + } + + /** + * New helper method to ensure inputs are disabled when help is visible + */ + disableInputsForHelp() { + // Get the input elements + const desktopInput = this.root.querySelector('.answer-input'); + + // Disable input while help is visible + if (desktopInput) { + desktopInput.disabled = true; + } + + // Disable submit button (desktop) + const submitButton = this.root.querySelector('.submit-answer'); + if (submitButton) { + submitButton.disabled = true; + submitButton.style.opacity = '0.5'; + submitButton.style.pointerEvents = 'none'; + } + + // For mobile, disable keyboard and input without hiding + if (this.isMobile) { + // Make custom input appear disabled + const customInput = this.root.querySelector('.custom-input'); + if (customInput) { + customInput.style.opacity = '0.5'; + customInput.style.pointerEvents = 'none'; + } + + // Disable all keyboard keys + const keyboardKeys = this.root.querySelectorAll('.keyboard-key'); + keyboardKeys.forEach(key => { + key.disabled = true; + key.style.opacity = '0.5'; + key.style.pointerEvents = 'none'; + }); + + // Disable mobile submit button + const mobileSubmitButton = this.root.querySelector('.mobile-submit-button'); + if (mobileSubmitButton) { + mobileSubmitButton.disabled = true; + mobileSubmitButton.style.opacity = '0.5'; + mobileSubmitButton.style.pointerEvents = 'none'; + } + } + } + + /** + * Updates the UI to match the current input mode + */ + updateInputModeUI() { + // Replace the input container HTML + const inputContainer = this.root.querySelector('.input-container'); + if (!inputContainer) return; + + // Store current input value before replacing the container + let currentInputValue = ''; + if (this.isMobile) { + currentInputValue = this.customInputValue || ''; + } else { + const inputEl = this.root.querySelector('.answer-input'); + currentInputValue = inputEl ? inputEl.value : ''; + } + + // Generate fresh input container HTML based on current mode + inputContainer.innerHTML = this.generateInputContainerHTML(); + + // Re-setup input handlers + this.setupInputHandlers(); + + // Restore input value + if (this.isMobile) { + this.customInputValue = currentInputValue; + this.updateCustomInputDisplay(); + } else { + const inputEl = this.root.querySelector('.answer-input'); + if (inputEl) inputEl.value = currentInputValue; + } + + // Re-render to show updated stats if the puzzle is complete + if (this.isPuzzleComplete()) { + const keystrokeStats = this.root.querySelector('.keystroke-stats'); + if (keystrokeStats) { + this.renderCompletionStats(keystrokeStats); + } + } + } + /**************** + State Management + *****************/ + + /** + * Updates game state while maintaining consistency + * @param {Partial<GameState>} updates - State properties to update + */ + updateGameState(updates) { + // Create new state with updates + const newState = { + ...this.state, + ...updates + }; + + // Ensure Sets remain Sets + if (updates.solvedExpressions) { + newState.solvedExpressions = new Set(updates.solvedExpressions); + } + if (updates.hintModeClues) { + newState.hintModeClues = new Set(updates.hintModeClues); + } + if (updates.peekedClues) { + newState.peekedClues = new Set(updates.peekedClues); + } + if (updates.megaPeekedClues) { + newState.megaPeekedClues = new Set(updates.megaPeekedClues); + } + + // Update active clues if display state changes + if (updates.displayState) { + newState.activeClues = this.findActiveClues(updates.displayState); + } + + // Update state and persist + this.state = newState; + this.saveState(); + } + + /** + * Gets the storage key for the current puzzle - enhanced version with explicit date parameter + * @param {string} puzzleDate - Optional specific puzzle date + * @returns {string} Storage key + */ + getStorageKey(puzzleDate) { + const dateToUse = puzzleDate || this.currentPuzzleDate; + if (!dateToUse) { + console.error('No puzzle date available when getting storage key!'); + return null; + } + return `bracketPuzzle_${dateToUse}`; + } + + /** + * Enhanced loadSavedState method with input mode handling + * @param {string} targetDate - The puzzle date to load state for + * @returns {Object|null} Saved state or null if none exists or validation fails + */ + async loadSavedState(targetDate) { + try { + // Ensure we're loading state for the correct puzzle date + const puzzleDate = targetDate || this.currentPuzzleDate; + + if (!puzzleDate) { + console.warn('Cannot load state: missing puzzle date'); + return null; + } + + const storageKey = `bracketPuzzle_${puzzleDate}`; + + if (!this.PUZZLE_DATA) { + console.warn('Cannot load state: missing puzzle data'); + return null; + } + + const savedData = localStorage.getItem(storageKey); + if (!savedData) { + console.log('No saved state found for key:', storageKey); + return null; + } + + const parsed = JSON.parse(savedData); + + // Enhanced validation: verify the puzzle date in the saved state matches the requested date + if (parsed.puzzleDate !== puzzleDate) { + console.warn(`Saved state puzzle date (${parsed.puzzleDate}) doesn't match requested date (${puzzleDate})`); + return null; + } + + // Additional validation: verify the initialPuzzle matches + if (parsed.initialPuzzle !== this.PUZZLE_DATA.initialPuzzle) { + console.warn('Saved state initialPuzzle doesn\'t match current puzzle data'); + return null; + } + + return { + displayState: parsed.displayState, + solvedExpressions: parsed.solvedExpressions || [], + message: '', // Always start with empty message + messageType: null, + totalKeystrokes: parsed.totalKeystrokes || 0, + minimumKeystrokes: parsed.minimumKeystrokes || this.calculateMinimumKeystrokes(), + hintModeClues: parsed.hintModeClues || [], + peekedClues: parsed.peekedClues || [], + megaPeekedClues: parsed.megaPeekedClues || [], + initialPuzzle: parsed.initialPuzzle, + puzzleDate: parsed.puzzleDate, + wrongGuesses: parsed.wrongGuesses || 0, + // Include the saved input mode if it exists + inputMode: parsed.inputMode || this.getInputMode(), + // Preserve help system state properties if they exist + helpVisible: parsed.helpVisible || false, + previousDisplay: parsed.previousDisplay || null + }; + + } catch (error) { + console.error('Error loading saved state:', error); + return null; + } + } + + saveState() { + try { + const storageKey = this.getStorageKey(); + if (!storageKey) { + console.error('Cannot save state: missing storage key'); + return false; + } + + const stateToSave = { + displayState: this.state.displayState, + solvedExpressions: Array.from(this.state.solvedExpressions), + totalKeystrokes: this.state.totalKeystrokes, + minimumKeystrokes: this.state.minimumKeystrokes, + hintModeClues: Array.from(this.state.hintModeClues), + peekedClues: Array.from(this.state.peekedClues), + megaPeekedClues: Array.from(this.state.megaPeekedClues), + initialPuzzle: this.PUZZLE_DATA.initialPuzzle, + puzzleDate: this.currentPuzzleDate, + isComplete: this.state.isComplete || false, + completionStats: this.state.completionStats || null, + wrongGuesses: this.state.wrongGuesses || 0, + inputMode: this.inputMode // Save the input mode with puzzle state + }; + + localStorage.setItem(storageKey, JSON.stringify(stateToSave)); + return true; + + } catch (error) { + console.error('Error saving state:', error); + return false; + } + } + + + /** + * Resets puzzle state and storage + */ + resetPuzzle() { + if (!confirm('Are you sure you want to reset the puzzle? All progress will be lost.')) { + return; + } + + // Clear saved state. + const storageKey = this.getStorageKey(); + if (storageKey) { + localStorage.removeItem(storageKey); + } + + // Get the user's preferred input mode + const preferredInputMode = this.getInputMode(); + + // Update the current input mode to the preferred mode + this.inputMode = preferredInputMode; + + // Update the rankCalculator's input mode + this.rankCalculator.inputMode = this.inputMode; + + // Reset game state + this.updateGameState({ + displayState: this.PUZZLE_DATA.initialPuzzle, + solvedExpressions: new Set(), + message: '', + totalKeystrokes: 0, + minimumKeystrokes: this.calculateMinimumKeystrokes(), + activeClues: this.findActiveClues(this.PUZZLE_DATA.initialPuzzle), + hintModeClues: new Set(), + peekedClues: new Set(), + megaPeekedClues: new Set(), + helpVisible: false, + previousDisplay: null, + wrongGuesses: 0 // Reset wrong guesses + }); + + // Remove any existing completion message and "completed" classes. + const completionMessage = this.root.querySelector('.completion-message'); + if (completionMessage) { + completionMessage.remove(); + } + const container = this.root.querySelector('.puzzle-container'); + if (container) { + container.classList.remove('completed'); + } + const puzzleDisplay = this.root.querySelector('.puzzle-display'); + if (puzzleDisplay) { + puzzleDisplay.classList.remove('completed'); + } + + // Rebuild the UI within your dedicated container. + this.root.innerHTML = this.generateInitialHTML(); + + // Re‑attach event handlers. + this.setupInputHandlers(); + this.setupShareButtons(); + this.setupPuzzleDisplay(); + this.setupDatePicker(); + // Re‑attach navigation handlers. Use a timeout to allow the new DOM to settle. + setTimeout(() => { + this.setupNavigationHandlers(); + this.checkAdjacentPuzzles(this.currentPuzzleDate); + }, 0); + + // Make sure the input container is visible. + const inputContainer = this.root.querySelector('.input-container'); + if (inputContainer) { + inputContainer.style.display = 'block'; + } + if (!this.isMobile && this.root.querySelector('.answer-input')) { + this.root.querySelector('.answer-input').focus(); + } + + this.render(); + this.showMessage('Puzzle reset successfully!'); + } + + calculateMinimumKeystrokes() { + return Object.values(this.PUZZLE_DATA.solutions) + .reduce((total, solution) => total + solution.length, 0); + } + + /************* + Data and API + **************/ + + /** + * Fetches and processes puzzle data for a date + */ + async fetchPuzzleForDate(targetDate) { + try { + // Try to get puzzle for specified date + let response = await fetch(`${this.API_ENDPOINT}/${targetDate}`, { + cache: 'no-store', + headers: { + 'Cache-Control': 'no-cache', + 'Pragma': 'no-cache' + } + }); + + // If that date's puzzle doesn't exist, get latest puzzle + if (!response.ok) { + // Get base list of puzzles + response = await fetch(this.API_ENDPOINT, { + cache: 'no-store', + headers: { + 'Cache-Control': 'no-cache', + 'Pragma': 'no-cache' + } + }); + if (!response.ok) { + throw new Error('Unable to fetch puzzle list'); + } + + const puzzles = await response.json(); + if (!puzzles || puzzles.length === 0) { + throw new Error('No puzzles available'); + } + + // Find the most recent puzzle date + const latestPuzzleDate = puzzles.reduce((latest, current) => { + return latest > current ? latest : current; + }); + + // Fetch the latest puzzle + response = await fetch(`${this.API_ENDPOINT}/${latestPuzzleDate}`, { + cache: 'no-store', + headers: { + 'Cache-Control': 'no-cache', + 'Pragma': 'no-cache' + } + }); + if (!response.ok) { + throw new Error('Failed to fetch latest puzzle'); + } + } + + const puzzleData = await response.json(); + + /*console.log('Raw puzzle data received:', { + date: targetDate, + puzzleData, + timestamp: new Date().toISOString() + });*/ + + // Clean up escaped quotes and store in PUZZLE_DATA + this.PUZZLE_DATA = { + ...puzzleData, + initialPuzzle: this.unescapeString(puzzleData.initialPuzzle), + solutions: Object.fromEntries( + Object.entries(puzzleData.solutions).map(([key, value]) => [ + this.unescapeString(key), + this.unescapeString(value) + ]) + ) + }; + + return true; + + } catch (error) { + console.error('Error fetching puzzle:', error); + return false; + } + } + + unescapeString(str) { + if (typeof str !== 'string') return str; + return str.replace(/\\"/g, '"') + .replace(/\\'/g, "'") + .replace(/\\\\/g, "\\"); + } + + /*********************** + Date and URL management + ************************/ + + getNYCDate() { + // Create a date object + const date = new Date(); + + // Get the NY time options + const nyOptions = { + timeZone: 'America/New_York', + year: 'numeric', + month: '2-digit', + day: '2-digit' + }; + + // Format date in NY timezone + const nyDateStr = date.toLocaleString('en-US', nyOptions); + + // Split into components and rearrange into YYYY-MM-DD + const [month, day, year] = nyDateStr.split('/'); + return `${year}-${month}-${day}`; + } + + formatNYCDate(date = this.getNYCDate()) { + // Split the YYYY-MM-DD format + const [year, month, day] = date.split('-'); + + // Create a Date object in the NYC timezone + // First create a date string with the format that ensures it's interpreted as NYC time + const nycDateString = `${year}-${month}-${day}T12:00:00-05:00`; // Noon in NYC to avoid DST issues + const dateObj = new Date(nycDateString); + + // Format it for display using the NYC timezone + return dateObj.toLocaleDateString('en-US', { + month: 'long', + day: 'numeric', + year: 'numeric', + timeZone: 'America/New_York' + }); + } + + /** + * Updates URL with the current puzzle date + * @param {string} puzzleDate - Date in YYYY-MM-DD format + */ + updateURL(puzzleDate) { + const url = new URL(window.location.href); + + // If we have an encoded puzzle, don't modify URL + if (url.searchParams.has('d')) { + return; + } + + // Get today's date in NYC time zone + const todayPuzzleDate = this.getNYCDate(); + + // Check if we're using path format or query parameter format + const isPathFormat = window.location.pathname.match(/\/puzzles\/\d{4}\/\d+\/\d+/); + + if (isPathFormat) { + // If using path format, keep using path format + if (puzzleDate !== todayPuzzleDate) { + // Format the date components for the path + const [year, month, day] = puzzleDate.split('-'); + const newPath = `/puzzles/${year}/${parseInt(month)}/${parseInt(day)}`; + window.history.pushState({}, '', newPath); + } else { + // If it's today's puzzle, go to home + window.history.pushState({}, '', '/'); + } + } else { + // Using query parameter format, stick with that + url.searchParams.delete('date'); + if (puzzleDate !== todayPuzzleDate) { + url.searchParams.set('date', puzzleDate); + } + window.history.pushState({}, '', url.toString()); + } + } + + /** + * Gets puzzle date from URL or defaults to current NYC date + * @returns {string} Puzzle date in YYYY-MM-DD format + */ + getPuzzleDateFromURL() { + // NEW: First check path for /puzzles/YYYY/M/D pattern + const pathMatch = window.location.pathname.match(/\/puzzles\/(\d{4})\/(\d{1,2})\/(\d{1,2})/); + if (pathMatch) { + const year = pathMatch[1]; + const month = pathMatch[2].padStart(2, '0'); + const day = pathMatch[3].padStart(2, '0'); + const dateStr = `${year}-${month}-${day}`; + + if (this.isValidPuzzleDate(dateStr)) { + return dateStr; + } + } + + // UNCHANGED: Your existing query parameter logic stays exactly the same + const urlParams = new URLSearchParams(window.location.search); + + if (urlParams.has('d')) { + const decoded = PuzzleEncoder.decodePuzzle(urlParams.get('d')); + return decoded?.puzzleDate || this.getNYCDate(); + } + + const dateParam = urlParams.get('date'); + if (dateParam && this.isValidPuzzleDate(dateParam)) { + return dateParam; + } + + return this.getNYCDate(); + } + + /** + * Validates a puzzle date string + * @param {string} dateStr - Date string to validate + * @returns {boolean} Whether the date is valid + */ + isValidPuzzleDate(dateStr) { + // Check format (YYYY-MM-DD) + if (!/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) { + return false; + } + + const date = new Date(dateStr); + if (isNaN(date.getTime())) { + return false; + } + + // On dev servers, allow any valid date + if (this.isDevelopmentEnvironment()) { + return true; + } + + // On production, don't allow future dates + const nycDate = new Date(this.getNYCDate()); + return date <= nycDate; + } + + + /********** + Game logic + ***********/ + + /** + * Finds all active clues in the current puzzle state + * @param {string} puzzleText - Current puzzle text + * @returns {Array<{start: number, end: number, text: string, expression: string}>} + */ + 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<{expression: string, startIndex: number, endIndex: number}>} + */ + 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; + } + + /** + * Generates new state after solving a clue + * @param {Object} clue - Clue object + * @param {string} solution - Solution text + * @returns {string} New display state + */ + generateSolvedClueState(clue, solution) { + const cleanState = this.state.displayState.replace(/<\/?[^>]+(>|$)/g, ''); + const escapedSolution = solution + .replace(/"/g, '"') + .replace(/'/g, '''); + + return ( + cleanState.slice(0, clue.startIndex) + + `<mark class="solved">${escapedSolution}</mark>` + + cleanState.slice(clue.endIndex) + ); + } + + /** + * 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(); + } + + /** + * Handles submission in submit mode + */ + 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); + + // Check if puzzle is complete after solving + if (this.isPuzzleComplete()) { + this.renderCompletedPuzzle( + this.root.querySelector('.puzzle-display'), + this.root.querySelector('.input-container'), + this.root.querySelector('.keystroke-stats') + ); + } + } else { + // Increment wrong guesses counter and show error message + this.updateGameState({ + wrongGuesses: (this.state.wrongGuesses || 0) + 1 + }); + + this.showMessage('Incorrect!', 'error'); + } + + // Clear input + if (this.isMobile) { + this.customInputValue = ''; + this.updateCustomInputDisplay(); + } else { + this.answerInput.value = ''; + this.answerInput.focus(); + } + } + + /** + * 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; + + const isFirstSolve = this.state.solvedExpressions.size === 0; + + // Generate the new puzzle display state by replacing the solved expression + const cleanState = this.state.displayState.replace(/<\/?[^>]+(>|$)/g, ''); + const escapedSolution = solution + .replace(/"/g, '"') + .replace(/'/g, '''); + + const newDisplayState = + cleanState.slice(0, startIndex) + + `<mark class="solved">${escapedSolution}</mark>` + + cleanState.slice(endIndex); + + // Add new expression at the beginning of the Set to maintain reverse chronological order + const updatedExpressions = new Set([expression, ...Array.from(this.state.solvedExpressions)]); + + // turn off tutorial prompt + this.turnOffTutorialPulse(); + + // Update game state with the new display and mark the expression as solved + this.updateGameState({ + displayState: newDisplayState, + solvedExpressions: updatedExpressions + }); + + // Show a success message (auto-clears later) + this.showMessage('Correct!', 'success'); + + // Clear the input based on device type: + if (this.isMobile) { + this.customInputValue = ''; + if (this.customInputEl) { + this.updateCustomInputDisplay(); + } + } else { + if (this.answerInput) { + this.answerInput.value = ''; + } + } + + // Re-render the puzzle display + requestAnimationFrame(() => { + this.render(); + }); + } + + /** + * Processes a solution reveal for megapeek + * @param {Object} match - Expression match information + */ + 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 = this.unescapeString( + cleanState.slice(startIndex + 1, endIndex - 1).trim() + ); + + if (this.unescapeString(expression) === currentExpression) { + const escapedSolution = solution + .replace(/"/g, '"') + .replace(/'/g, '''); + + const newDisplayState = + cleanState.slice(0, startIndex) + + `<mark class="solved">${escapedSolution}</mark>` + + cleanState.slice(endIndex); + + return newDisplayState; + } + } + return null; + } + + /************ + Hint system + *************/ + + /** + * Toggles hint mode for an expression between peek (show length) and mega peek (reveal answer) + * @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 length) + * @param {string} expression - The expression to peek at + */ + handleFirstPeek(expression) { + // Add confirmation dialog for regular peek + if (!confirm('You sure you want to peek at the first letter of this answer?')) { + return; + } + + this.updateGameState({ + hintModeClues: new Set([...this.state.hintModeClues, expression]), + peekedClues: new Set([...this.state.peekedClues, expression]) + }); + + this.render(); + } + + /** + * Handles the mega peek action (reveals answer) + * @param {string} expression - The expression to reveal + */ + handleMegaPeek(expression) { + if (!confirm('You sure you want to reveal this answer?')) { + return; + } + + 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; + } + + this.updateGameState({ + displayState: newDisplayState, + totalKeystrokes: this.state.totalKeystrokes + solution.length, + megaPeekedClues: new Set([...this.state.megaPeekedClues, expression]), + solvedExpressions: new Set([expression, ...Array.from(this.state.solvedExpressions)]), + hintModeClues: new Set([...this.state.hintModeClues].filter(e => e !== expression)) + }); + + this.render(); + } + + /*************** + Input Handling + ****************/ + + /** + * Conservatively detects if the current device is a tablet (iPad or Android) + * @returns {boolean} True if the device is confidently identified as a tablet + */ + isTablet() { + // IPAD DETECTION + const isLegacyIPad = /iPad/i.test(navigator.userAgent); + const isModernIPad = !(/iPhone|iPod/.test(navigator.userAgent)) && + navigator.platform === 'MacIntel' && + navigator.maxTouchPoints > 1 && + !window.MSStream; + + // Any iPad detection is sufficient + const isIPad = isLegacyIPad || isModernIPad; + + // ANDROID TABLET DETECTION + // Standard way: Android without "Mobile" in UA string typically means tablet + const isAndroidTablet = /Android/i.test(navigator.userAgent) && + !/Mobile/i.test(navigator.userAgent); + + // Alternative Android tablet signals + const hasExplicitTabletIdentifier = /Tablet|Tab/i.test(navigator.userAgent) || + /SM-T[0-9]{3}/i.test(navigator.userAgent); // Samsung Galaxy Tab model numbers + + // GENERIC TABLET SIZE CHECK (as backup, not primary signal) + // Most tablets have at least one dimension ≥ 768px + const hasTabletDimensions = Math.max( + screen.width, + screen.height, + window.innerWidth, + window.innerHeight + ) >= 768; + + // EXPLICIT PHONE DETECTION (to avoid false positives) + const isExplicitlyPhone = /iPhone|iPod|Android.*Mobile|Mobile.*Android|BlackBerry|IEMobile|Opera Mini|Windows Phone/i.test(navigator.userAgent); + + // Combined tablet detection: + return isIPad || ((isAndroidTablet || hasExplicitTabletIdentifier) && + hasTabletDimensions && + !isExplicitlyPhone); + } + + /** + * Sets up input handlers based on current mode + */ + setupInputHandlers() { + if (this.useCustomKeyboard) { + // Use custom keyboard for phones + this.setupMobileInput(); + } else { + // Use native keyboard for tablets and desktops + this.setupDesktopInput(); + } + } + + /** + * Sets up desktop input handlers + */ + setupDesktopInput() { + this.answerInput = this.root.querySelector('.answer-input'); + const submitButton = this.root.querySelector('.submit-answer'); + + if (!this.answerInput) { + console.error('Answer input element not found'); + return; + } + + if (this.inputMode === 'submit') { + // Submit mode - only handle input on submit button click + if (submitButton) { + submitButton.addEventListener('click', () => { + this.handleSubmission(); + }); + } + + // Handle Enter key press + this.answerInput.addEventListener('keypress', (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + + // Apply visual feedback if submit button exists + if (submitButton) { + submitButton.style.transform = 'scale(0.95)'; + submitButton.style.backgroundColor = '#1e40af'; + + // Remove the styles after a short delay + setTimeout(() => { + submitButton.style.transform = ''; + submitButton.style.backgroundColor = ''; + }, 85); + } + + this.handleSubmission(); + } + }); + } else { + // Classic mode - auto-snap answers as typed + this.answerInput.addEventListener('input', (e) => { + this.handleInput(e.target.value); + }); + } + + this.answerInput.addEventListener('keydown', (e) => { + this.handleKeyDown(e); + }); + + this.answerInput.addEventListener('paste', (e) => { + e.preventDefault(); + }); + + this.answerInput.focus(); + } + + /** + * Sets up mobile input handlers + */ + setupMobileInput() { + this.customInputValue = 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 = this.inputMode === 'classic' ? 'start typing answers...' : 'type any answer...'; + this.customInputEl.appendChild(placeholderEl); + + // Set up the mobile submit button (if in submit mode) + if (this.inputMode === 'submit') { + const mobileSubmitButton = this.root.querySelector('.mobile-submit-button'); + if (mobileSubmitButton) { + mobileSubmitButton.addEventListener('click', () => { + this.handleSubmission(); + }); + } + } + + // Clean up any existing listeners first + const oldKeyboardKeys = this.root.querySelectorAll('.keyboard-key'); + oldKeyboardKeys.forEach(keyEl => { + const newKeyEl = keyEl.cloneNode(true); + keyEl.parentNode.replaceChild(newKeyEl, keyEl); + }); + + // 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.updateGameState({ totalKeystrokes: this.state.totalKeystrokes + 1 }); + + } + + this.updateCustomInputDisplay(); + + // In classic mode, check for solution match after each key + if (this.inputMode === 'classic') { + this.handleInput(this.customInputValue); + } + }); + + // 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(); + } + + 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 = 'type any answer...'; + } + + // 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); + } + } + + /** + * Handles keydown events for keystroke tracking + * @param {KeyboardEvent} event - The keyboard event + */ + handleKeyDown(event) { + // turn off tutorial pulse + // Only count meaningful keystrokes (not control keys) + if (this.isCountableKeystroke(event)) { + this.updateGameState({ + totalKeystrokes: this.state.totalKeystrokes + 1 + }); + + } + } + + /** + * 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); + } + + /*********** + Navigation + ************/ + + /** + * Creates and attaches the date picker functionality + */ + setupDatePicker() { + // Find the date element to attach the picker to + const dateElement = this.root.querySelector('.puzzle-date'); + if (!dateElement) return; + + // Remove any existing date picker containers to prevent duplicates + const existingContainers = this.root.querySelectorAll('.date-picker-container'); + existingContainers.forEach(container => container.remove()); + + // Clean up any existing event listeners by cloning the element + const dateParent = dateElement.parentNode; + const newDateElement = dateElement.cloneNode(true); + dateParent.replaceChild(newDateElement, dateElement); + + // Make it look clickable + newDateElement.style.cursor = 'pointer'; + newDateElement.title = 'Tap to open puzzle calendar'; + + // Add visual indication for mobile users + const isMobile = /Android|iPhone|iPad|iPod/i.test(navigator.userAgent); + if (isMobile) { + // Add a calendar icon or more visible indicator for mobile + dateParent.style.position = 'relative'; + + // Enhance tap target size for mobile + newDateElement.style.padding = '8px 4px'; + newDateElement.style.position = 'relative'; + newDateElement.style.zIndex = '1'; + } + + // Create a container for the date picker (initially hidden) + const datePickerContainer = document.createElement('div'); + datePickerContainer.className = 'date-picker-container'; + datePickerContainer.style.display = 'none'; + datePickerContainer.style.position = 'absolute'; + datePickerContainer.style.top = '100%'; + datePickerContainer.style.left = '50%'; + datePickerContainer.style.transform = 'translateX(-50%)'; + datePickerContainer.style.zIndex = '1500'; // Increased z-index to ensure visibility + datePickerContainer.style.backgroundColor = 'white'; + datePickerContainer.style.boxShadow = '0 4px 12px rgba(0,0,0,0.2)'; + datePickerContainer.style.borderRadius = '0.5rem'; + datePickerContainer.style.padding = '1rem'; + datePickerContainer.style.marginTop = '0.5rem'; + datePickerContainer.style.maxWidth = isMobile ? '300px' : '340px'; + datePickerContainer.style.width = isMobile ? '90vw' : '100%'; + datePickerContainer.style.boxSizing = 'border-box'; // Ensure padding is included in width + + // Insert the container after the date element + dateParent.style.position = 'relative'; + dateParent.style.zIndex = '100'; // Ensure parent has reasonable z-index + dateParent.appendChild(datePickerContainer); + + // Toggle function to open/close the date picker + const toggleDatePicker = (e) => { + // Prevent default to avoid any browser handling + e.preventDefault(); + e.stopPropagation(); + + // Add log for debugging + console.log('Date picker toggle clicked', { + containerDisplay: datePickerContainer.style.display, + isCompletionState: this.isPuzzleComplete() + }); + + if (datePickerContainer.style.display === 'none') { + this.renderDatePicker(datePickerContainer); + datePickerContainer.style.display = 'block'; + + // Create a close handler that works for both mouse and touch + const closeCalendar = (event) => { + // Don't close if clicked on the date element or within the picker + if (!datePickerContainer.contains(event.target) && event.target !== newDateElement) { + datePickerContainer.style.display = 'none'; + document.removeEventListener('click', closeCalendar); + document.removeEventListener('touchend', closeCalendar); + } + }; + + // Add both mouse and touch event listeners to handle closing + document.addEventListener('click', closeCalendar); + document.addEventListener('touchend', closeCalendar); + } else { + datePickerContainer.style.display = 'none'; + } + }; + + // Add event listeners for both click and touch events with preventTouchFocus fix + const preventTouchFocus = (e) => { + e.preventDefault(); // Prevent focus/highlight issues on some mobile browsers + toggleDatePicker(e); + }; + + newDateElement.addEventListener('click', toggleDatePicker); + newDateElement.addEventListener('touchend', preventTouchFocus); + + // Add visible debug helper in development environments + if (this.isDevelopmentEnvironment()) { + newDateElement.setAttribute('data-debug', 'date-picker-trigger'); + } + } + + /** + * Renders the date picker with available puzzles + * @param {HTMLElement} container - The container to render the date picker in + */ + async renderDatePicker(container) { + // Show loading state + container.innerHTML = '<div class="loading-calendar">Loading calendar...</div>'; + + try { + // Fetch available puzzles + const availableDates = await this.fetchAvailablePuzzleDates(); + if (!availableDates || availableDates.length === 0) { + container.innerHTML = '<div class="error">No puzzles available</div>'; + return; + } + + // Get completion status for all puzzles + const puzzleStatuses = this.getPuzzleCompletionStatuses(availableDates); + + // Determine the current month and year (in NYC timezone) + const currentDateNYC = new Date(this.getNYCDate()); + let currentViewMonth = currentDateNYC.getMonth(); + let currentViewYear = currentDateNYC.getFullYear(); + + // Render initial calendar + this.renderMonthCalendar(container, currentViewMonth, currentViewYear, availableDates, puzzleStatuses); + + } catch (error) { + console.error('Error rendering date picker:', error); + container.innerHTML = '<div class="error">Failed to load calendar</div>'; + } + } + + /** + * Renders a specific month's calendar + * @param {HTMLElement} container - The container element + * @param {number} month - Month to render (0-11) + * @param {number} year - Year to render + * @param {string[]} availableDates - Array of available puzzle dates + * @param {Object} puzzleStatuses - Map of dates to completion status + */ + renderMonthCalendar(container, month, year, availableDates, puzzleStatuses) { + // Calculate NYC date strings correctly + const getConsistentDateStr = (year, month, day) => { + // This ensures we're constructing dates consistently as YYYY-MM-DD + return `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`; + }; + + // Get month info - using local date objects for display purposes only + const monthStart = new Date(year, month, 1); + const monthName = monthStart.toLocaleString('en-US', { month: 'long', year: 'numeric' }); + + // Get days in month + const daysInMonth = new Date(year, month + 1, 0).getDate(); + + // Get the day of week the month starts on (0 = Sunday, 6 = Saturday) + const startDay = monthStart.getDay(); + + // Get today's date in NYC + const todayNYC = this.getNYCDate(); + + // Create calendar HTML + let calendarHTML = ` + <div class="date-picker-header" style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;"> + <button class="month-nav prev" style="background: none; border: none; cursor: pointer; font-size: 1.2rem;">‹</button> + <h3 style="margin: 0; text-align: center; font-size: 1.2rem;">${monthName}</h3> + <button class="month-nav next" style="background: none; border: none; cursor: pointer; font-size: 1.2rem;">›</button> + </div> + + <div class="weekdays" style="display: grid; grid-template-columns: repeat(7, 1fr); text-align: center; font-weight: bold; margin-bottom: 0.5rem;"> + <div>Su</div> + <div>Mo</div> + <div>Tu</div> + <div>We</div> + <div>Th</div> + <div>Fr</div> + <div>Sa</div> + </div> + + <div class="calendar-grid" style="display: grid; grid-template-columns: repeat(7, 1fr); gap: 5px; width: 100%;"> + `; + + // Add empty cells for days before the month starts + for (let i = 0; i < startDay; i++) { + calendarHTML += `<div class="empty-day"></div>`; + } + + // Add cells for each day in the month + for (let day = 1; day <= daysInMonth; day++) { + // Format date string consistently + const dateStr = getConsistentDateStr(year, month, day); + + // Check if this date has a puzzle available + const isPuzzleAvailable = availableDates.includes(dateStr); + + // Get puzzle status for this date + const puzzleStatus = puzzleStatuses[dateStr] || 'unsolved'; + + // Determine if this is today's date + const isToday = dateStr === todayNYC; + + // Determine if this is the current puzzle date + const isCurrentPuzzle = dateStr === this.currentPuzzleDate; + + // Determine future dates (not yet available) + const isFutureDate = dateStr > todayNYC && !this.isDevelopmentEnvironment(); + + // Status-specific styles + let statusStyle = ''; + if (isPuzzleAvailable) { + switch (puzzleStatus) { + case 'complete': + statusStyle = 'background-color: #c6f6d5; color: #22543d; border: 1px solid #9ae6b4;'; + break; + case 'started': + statusStyle = 'background-color: #fefcbf; color: #744210; border: 1px solid #f6e05e;'; + break; + case 'unsolved': + statusStyle = 'background-color: #e9f5f9; color: #2c5282; border: 1px solid #bee3f8;'; + break; + default: + statusStyle = 'background-color: #fff; color: #1a202c; border: 1px solid #e2e8f0;'; + } + } else { + // No puzzle available + statusStyle = 'background-color: #f9f9f9; color: #a0aec0; border: 1px solid #edf2f7;'; + } + + // Today and current puzzle styles + const todayStyle = isToday ? 'font-weight: bold; border: 2px solid #4a5568;' : ''; + const currentStyle = isCurrentPuzzle ? 'box-shadow: 0 0 0 2px #4299e1;' : ''; + + // Cell classes and attributes + const cellClass = isPuzzleAvailable ? 'date-cell has-puzzle' : 'date-cell no-puzzle'; + const disabledAttr = (isFutureDate || !isPuzzleAvailable) ? 'disabled="true"' : ''; + const dataAttr = isPuzzleAvailable ? `data-date="${dateStr}" data-status="${puzzleStatus}"` : ''; + + // Add cell to calendar + calendarHTML += ` + <div + class="${cellClass}" + ${dataAttr} + ${disabledAttr} + style=" + padding: 0.4rem 0; + border-radius: 0.375rem; + text-align: center; + ${isFutureDate || !isPuzzleAvailable ? 'cursor: default; opacity: 0.5;' : 'cursor: pointer;'} + ${statusStyle} + ${todayStyle} + ${currentStyle} + " + title="${this.formatDateMonthDay(dateStr)}${isPuzzleAvailable ? ` (${puzzleStatus})` : ' (no puzzle)'}" + > + ${day} + </div> + `; + } + + calendarHTML += ` + </div> + <div class="calendar-footer" style="margin-top: 1rem; font-size: 0.8rem; display: flex; justify-content: center; gap: 1rem;"> + <span style="display: flex; align-items: center;"> + <span style="display: inline-block; width: 12px; height: 12px; margin-right: 4px; background-color: #c6f6d5; border: 1px solid #9ae6b4; border-radius: 2px;"></span> Complete + </span> + <span style="display: flex; align-items: center;"> + <span style="display: inline-block; width: 12px; height: 12px; margin-right: 4px; background-color: #fefcbf; border: 1px solid #f6e05e; border-radius: 2px;"></span> Started + </span> + <span style="display: flex; align-items: center;"> + <span style="display: inline-block; width: 12px; height: 12px; margin-right: 4px; background-color: #e9f5f9; border: 1px solid #bee3f8; border-radius: 2px;"></span> Available + </span> + </div> + `; + + // Set the calendar HTML + container.innerHTML = calendarHTML; + + // Add navigation handlers + this.setupMonthNavigation(container, month, year, availableDates, puzzleStatuses); + + // Add date selection handlers + this.setupDateSelectionHandlers(container); + } + + /** + * Format date as "Month Day" (e.g. "March 15") + * @param {string} dateStr - Date string in YYYY-MM-DD format + * @returns {string} Formatted date + */ + formatDateMonthDay(dateStr) { + // Parse the YYYY-MM-DD string directly + const [year, month, day] = dateStr.split('-'); + + // Create a date string that displays as intended + return `${new Date(Number(year), Number(month) - 1, Number(day)).toLocaleDateString('en-US', { + month: 'long', + day: 'numeric' + })}`; + } + + /** + * Sets up month navigation buttons + * @param {HTMLElement} container - Container element + * @param {number} currentMonth - Current month view (0-11) + * @param {number} currentYear - Current year view + * @param {string[]} availableDates - Available puzzle dates + * @param {Object} puzzleStatuses - Puzzle completion statuses + */ + setupMonthNavigation(container, currentMonth, currentYear, availableDates, puzzleStatuses) { + const prevButton = container.querySelector('.month-nav.prev'); + const nextButton = container.querySelector('.month-nav.next'); + + // Make buttons more touch-friendly + const enhanceButton = (button) => { + button.style.width = '36px'; + button.style.height = '36px'; + button.style.fontSize = '1.5rem'; + button.style.cursor = 'pointer'; + button.style.userSelect = 'none'; + button.style.touchAction = 'manipulation'; + }; + + enhanceButton(prevButton); + enhanceButton(nextButton); + + // Handler for previous month - Works with both mouse and touch + const handlePrevMonth = (e) => { + e.preventDefault(); + e.stopPropagation(); + + let newMonth = currentMonth - 1; + let newYear = currentYear; + + if (newMonth < 0) { + newMonth = 11; + newYear--; + } + + this.renderMonthCalendar(container, newMonth, newYear, availableDates, puzzleStatuses); + }; + + // Handler for next month - Works with both mouse and touch + const handleNextMonth = (e) => { + e.preventDefault(); + e.stopPropagation(); + + let newMonth = currentMonth + 1; + let newYear = currentYear; + + if (newMonth > 11) { + newMonth = 0; + newYear++; + } + + this.renderMonthCalendar(container, newMonth, newYear, availableDates, puzzleStatuses); + }; + + // Add event listeners for both mouse and touch events + prevButton.addEventListener('click', handlePrevMonth); + prevButton.addEventListener('touchend', handlePrevMonth); + + nextButton.addEventListener('click', handleNextMonth); + nextButton.addEventListener('touchend', handleNextMonth); + } + + /** + * Sets up event handlers for date selection + * @param {HTMLElement} container - Calendar container + */ + setupDateSelectionHandlers(container) { + // Find all date cells with puzzles + const dateCells = container.querySelectorAll('.date-cell.has-puzzle:not([disabled])'); + + dateCells.forEach(cell => { + // Create a visual feedback effect for touch + cell.style.transition = 'transform 0.1s ease, opacity 0.1s ease'; + + // Enhanced tap/click area + cell.style.margin = '1px'; + cell.style.position = 'relative'; + + // Handler function that works for both click and touch + const handleDateSelection = async (e) => { + e.preventDefault(); + e.stopPropagation(); // Prevent closing the calendar immediately + + // Visual feedback + cell.style.transform = 'scale(0.95)'; + cell.style.opacity = '0.8'; + + // Reset visual feedback after a short delay + setTimeout(() => { + cell.style.transform = 'scale(1)'; + cell.style.opacity = '1'; + }, 150); + + const targetDate = cell.dataset.date; + if (!targetDate || targetDate === this.currentPuzzleDate) { + // Already on this puzzle or invalid date, just close the picker + container.style.display = 'none'; + return; + } + + // Navigate to selected puzzle + container.style.display = 'none'; + + await this.navigateToPuzzle(targetDate); + }; + + // Add both mouse and touch event listeners + cell.addEventListener('click', handleDateSelection); + cell.addEventListener('touchend', handleDateSelection); + }); + } + + /** + * Fetches all available puzzle dates from the API + * @returns {Promise<string[]>} Array of available dates in YYYY-MM-DD format + */ + async fetchAvailablePuzzleDates() { + try { + const response = await fetch(this.API_ENDPOINT, { + cache: 'no-store', + headers: { + 'Cache-Control': 'no-cache', + 'Pragma': 'no-cache' + } + }); + + if (!response.ok) { + throw new Error('Failed to fetch available puzzles'); + } + + const puzzles = await response.json(); + + // Filter out future puzzles if not in dev environment + const today = this.getNYCDate(); + const isDevEnvironment = this.isDevelopmentEnvironment(); + + return puzzles + .map(puzzle => typeof puzzle === 'string' ? puzzle : (puzzle.puzzleDate || puzzle)) + .filter(date => isDevEnvironment || date <= today) + .sort(); + + } catch (error) { + console.error('Error fetching available puzzles:', error); + return []; + } + } + + /** + * Scans localStorage to determine completion status for all puzzles + * @param {string[]} dates - Array of puzzle dates to check + * @returns {Object} Map of dates to completion status + */ + getPuzzleCompletionStatuses(dates) { + const statusMap = {}; + + dates.forEach(date => { + const storageKey = `bracketPuzzle_${date}`; + const savedData = localStorage.getItem(storageKey); + + if (!savedData) { + // No saved data - puzzle exists but not started + statusMap[date] = 'unsolved'; + } else { + try { + const puzzleData = JSON.parse(savedData); + + // Check if puzzle is complete + if (puzzleData.isComplete || !puzzleData.displayState.includes('[')) { + statusMap[date] = 'complete'; + } else if (this.isPuzzleStarted(puzzleData)) { + statusMap[date] = 'started'; + } else { + statusMap[date] = 'unsolved'; + } + } catch (e) { + console.error('Error parsing saved puzzle data:', e); + statusMap[date] = 'unsolved'; + } + } + }); + + return statusMap; + } + + /** + * Checks for available puzzles before and after current date + * @param {string} currentDate - Current puzzle date in YYYY-MM-DD format + */ + async checkAdjacentPuzzles(currentDate) { + try { + // Get current NYC date for availability check + const nycDate = this.getNYCDate(); + const isDevEnvironment = this.isDevelopmentEnvironment(); + + // 1. Fetch available puzzles + const response = await fetch(this.API_ENDPOINT, { + cache: 'no-store', + headers: { + 'Cache-Control': 'no-cache', + 'Pragma': 'no-cache' + } + }); + + if (!response.ok) { + throw new Error('Unable to fetch puzzle list'); + } + + const puzzles = await response.json(); + if (!puzzles?.length) { + console.log('No puzzles available'); + return this.updateAdjacentPuzzles(null, null); + } + + // 2. Extract and normalize dates + const puzzleDates = puzzles + .map(puzzle => puzzle.puzzleDate || puzzle.date) + .filter(Boolean) + .sort(); // Sort chronologically + + // 3. Find current puzzle's position + const currentIndex = puzzleDates.indexOf(currentDate); + if (currentIndex === -1) { + console.warn('Current puzzle not found in available puzzles'); + return this.updateAdjacentPuzzles(null, null); + } + + // 4. Determine previous puzzle (always available) + const previousPuzzle = currentIndex > 0 ? puzzleDates[currentIndex - 1] : null; + + // 5. Determine next puzzle (with availability check) + let nextPuzzle = null; + if (currentIndex < puzzleDates.length - 1) { + const potentialNext = puzzleDates[currentIndex + 1]; + // Allow future puzzles in dev, restrict in production + if (isDevEnvironment || potentialNext <= nycDate) { + nextPuzzle = potentialNext; + } + } + + // 6. Update navigation state + this.updateAdjacentPuzzles(previousPuzzle, nextPuzzle); + + } catch (error) { + console.error('Error checking adjacent puzzles:', error); + // Fail gracefully - disable navigation if we can't determine availability + this.updateAdjacentPuzzles(null, null); + } + } + + /** + * Updates navigation state and UI for adjacent puzzles + * @param {string|null} previous - Previous puzzle date + * @param {string|null} next - Next puzzle date + */ + updateAdjacentPuzzles(previous, next) { + // Update state + this.adjacentPuzzleDates = { previous, next }; + + // Update UI + const prevButton = this.root.querySelector('.nav-button.prev'); + const nextButton = this.root.querySelector('.nav-button.next'); + + if (prevButton) { + prevButton.disabled = !previous; + prevButton.style.opacity = previous ? '1' : '0.3'; + prevButton.style.cursor = previous ? 'pointer' : 'default'; + } + + if (nextButton) { + nextButton.disabled = !next; + nextButton.style.opacity = next ? '1' : '0.3'; + nextButton.style.cursor = next ? 'pointer' : 'default'; + } + } + + /** + * Improved navigateToPuzzle method to handle input mode properly + * @param {string} targetDate - Date to navigate to + */ + async navigateToPuzzle(targetDate) { + try { + console.log('Starting navigation to date:', targetDate); + this.setNavigationLoading(true); + + // Store the previous date before changing + const previousDate = this.currentPuzzleDate; + + // Store the current preferred input mode (from localStorage) + const preferredInputMode = this.getInputMode(); + + // Reset mobile input state before navigation + if (this.isMobile) { + this.customInputValue = ''; + this.customInputEl = null; + } + + // Fetch and process new puzzle data + await this.fetchPuzzleForDate(targetDate); + + if (!this.PUZZLE_DATA) { + throw new Error(`Failed to fetch puzzle for ${targetDate}`); + } + + // Important: Update current puzzle date AFTER successful fetch but BEFORE loading state + this.currentPuzzleDate = targetDate; + this.updateURL(targetDate); + + // Check for available adjacent puzzles + await this.checkAdjacentPuzzles(targetDate); + + // Reset state to prevent leakage + this.state = { + displayState: '', + solvedExpressions: new Set(), + message: '', + messageType: null, + totalKeystrokes: 0, + minimumKeystrokes: 0, + activeClues: [], + hintModeClues: new Set(), + peekedClues: new Set(), + megaPeekedClues: new Set(), + wrongGuesses: 0, + helpVisible: false, + previousDisplay: null + }; + + // Try to load saved state for this puzzle with explicit date parameter + const savedState = await this.loadSavedState(targetDate); + + // Initialize game state + if (savedState && savedState.puzzleDate === targetDate) { // Extra validation + console.log(`Loading saved state for ${targetDate}`); + + // Check if the saved state has an input mode + const savedInputMode = savedState.inputMode; + + // If the puzzle has been started (has progress), respect its saved mode + // Otherwise use the preferred mode + const hasPuzzleProgress = this.isPuzzleStarted(savedState); + + // Set the input mode based on progress status + if (hasPuzzleProgress && savedInputMode) { + this.inputMode = savedInputMode; + } else { + this.inputMode = preferredInputMode; + } + + // Update rankCalculator with the correct input mode + this.rankCalculator.inputMode = this.inputMode; + + this.updateGameState({ + displayState: savedState.displayState, + solvedExpressions: new Set(savedState.solvedExpressions || []), + totalKeystrokes: savedState.totalKeystrokes || 0, + minimumKeystrokes: savedState.minimumKeystrokes || this.calculateMinimumKeystrokes(), + activeClues: this.findActiveClues(savedState.displayState), + hintModeClues: new Set(savedState.hintModeClues || []), + peekedClues: new Set(savedState.peekedClues || []), + megaPeekedClues: new Set(savedState.megaPeekedClues || []), + wrongGuesses: savedState.wrongGuesses || 0, + helpVisible: savedState.helpVisible || false, + previousDisplay: savedState.previousDisplay || null + }); + } else { + console.log(`Initializing fresh state for ${targetDate}`); + + // For a fresh puzzle, use the preferred input mode + this.inputMode = preferredInputMode; + + // Update rankCalculator with the correct input mode + this.rankCalculator.inputMode = this.inputMode; + + this.updateGameState({ + displayState: this.PUZZLE_DATA.initialPuzzle, + solvedExpressions: new Set(), + message: '', + totalKeystrokes: 0, + minimumKeystrokes: this.calculateMinimumKeystrokes(), + activeClues: this.findActiveClues(this.PUZZLE_DATA.initialPuzzle), + hintModeClues: new Set(), + peekedClues: new Set(), + megaPeekedClues: new Set(), + wrongGuesses: 0, + helpVisible: false, + previousDisplay: null + }); + } + + // Reset UI and reattach handlers + this.root.innerHTML = this.generateInitialHTML(); + this.setupInputHandlers(); + this.setupShareButtons(); + this.setupPuzzleDisplay(); + this.setupNavigationHandlers(); + + // Render with new state + this.render(); + + this.setupDatePicker(); + } catch (error) { + console.error('Navigation failed:', error); + this.showErrorMessage('Failed to load puzzle. Please try again.'); + } finally { + this.setNavigationLoading(false); + } + } + + /** + * Sets the loading state for navigation buttons + */ + setNavigationLoading(isLoading) { + const prevButton = this.root.querySelector('.nav-button.prev'); + const nextButton = this.root.querySelector('.nav-button.next'); + + if (prevButton) { + prevButton.disabled = isLoading || !this.adjacentPuzzleDates.previous; + prevButton.style.cursor = isLoading ? 'wait' : 'pointer'; + prevButton.style.opacity = prevButton.disabled ? '0.5' : '1'; + } + if (nextButton) { + nextButton.disabled = isLoading || !this.adjacentPuzzleDates.next; + nextButton.style.cursor = isLoading ? 'wait' : 'pointer'; + nextButton.style.opacity = nextButton.disabled ? '0.5' : '1'; + } + } + + /** + * Sets up navigation button event handlers + */ + setupNavigationHandlers() { + // First remove any existing handlers to prevent duplicates + this.cleanupEventListeners(); + + const prevButton = this.root.querySelector('.nav-button.prev'); + const nextButton = this.root.querySelector('.nav-button.next'); + + // Track if navigation is in progress to prevent double-clicks + let isNavigating = false; + + // Previous puzzle handler + if (prevButton) { + prevButton.addEventListener('click', async () => { + if (isNavigating || !this.adjacentPuzzleDates.previous) return; + + try { + isNavigating = true; + prevButton.style.cursor = 'wait'; + await this.navigateToPuzzle(this.adjacentPuzzleDates.previous); + } catch (error) { + console.error('Navigation failed:', error); + this.showErrorMessage('Failed to load previous puzzle. Please try again.'); + } finally { + isNavigating = false; + prevButton.style.cursor = this.adjacentPuzzleDates.previous ? 'pointer' : 'default'; + } + }); + } + + // Next puzzle handler + if (nextButton) { + nextButton.addEventListener('click', async () => { + if (isNavigating || !this.adjacentPuzzleDates.next) return; + + try { + isNavigating = true; + nextButton.style.cursor = 'wait'; + await this.navigateToPuzzle(this.adjacentPuzzleDates.next); + } catch (error) { + console.error('Navigation failed:', error); + this.showErrorMessage('Failed to load next puzzle. Please try again.'); + } finally { + isNavigating = false; + nextButton.style.cursor = this.adjacentPuzzleDates.next ? 'pointer' : 'default'; + } + }); + } + } + + /** + * Removes existing event listeners to prevent duplicates + */ + cleanupEventListeners() { + const prevButton = this.root.querySelector('.nav-button.prev'); + const nextButton = this.root.querySelector('.nav-button.next'); + + if (prevButton) { + const newPrevButton = prevButton.cloneNode(true); + prevButton.parentNode.replaceChild(newPrevButton, prevButton); + } + + if (nextButton) { + const newNextButton = nextButton.cloneNode(true); + nextButton.parentNode.replaceChild(newNextButton, nextButton); + } + } + + /************* + UI rendering + **************/ + + /* + * Main render method - updates all UI elements based on current state + */ + render() { + const elements = this.getDOMElements(); + + // Early return if required elements aren't found + if (!this.validateElements(elements)) { + console.error('Required DOM elements not found'); + return; + } + + this.renderPuzzleDisplay(elements.puzzleDisplay); + this.renderSolvedExpressions(elements.expressionsList); + this.renderMessage(elements.message); + + // Handle completion state + if (this.isPuzzleComplete()) { + this.renderCompletedPuzzle(elements.puzzleDisplay, elements.inputContainer, elements.keystrokeStats); + } else { + this.renderInProgressState(elements.keystrokeStats); + } + } + + /** + * Renders the main puzzle display + * @param {HTMLElement} displayElement - Puzzle display element + */ + renderPuzzleDisplay(displayElement) { + let highlightedState = this.applyActiveClueHighlights(this.state.displayState); + highlightedState = this.replaceUnderscoreSequences(highlightedState); + displayElement.innerHTML = highlightedState; + } + + replaceUnderscoreSequences(text) { + // Regular expression to match sequences of 2 or more underscores + return text.replace(/_{2,}/g, (match) => { + // Calculate width based on number of underscores (em units work well here) + const width = (match.length * 0.6) + 'em'; + return `<span class="blank-line" style="width: ${width};"></span>`; + }); + } + + /** + * Renders the solved expressions list + * @param {HTMLElement} listElement - Expressions list element + */ + renderSolvedExpressions(listElement) { + if (!listElement) return; + + const solvedHTML = Array.from(this.state.solvedExpressions) + .map(expression => this._generateSolvedExpressionItemHTML( + expression, + this.PUZZLE_DATA.solutions[expression] + )) + .join(''); + + listElement.innerHTML = solvedHTML; + } + + /** + * Renders the current message + * @param {HTMLElement} messageElement - Message element + */ + renderMessage(messageElement) { + if (!messageElement) return; + + if (this.state.message) { + messageElement.textContent = this.state.message; + messageElement.className = `message ${this.state.messageType || 'success'}`; + + // Apply error styling if needed + if (this.state.messageType === 'error') { + messageElement.style.cssText = ` + padding: 1rem; + margin: 1rem; + background-color: #fee2e2; + color: #ef4444; + border-radius: 0.375rem; + `; + } else { + messageElement.style.cssText = ''; // Reset styles for non-error messages + } + } else { + messageElement.textContent = ''; + messageElement.className = 'message'; + messageElement.style.cssText = ''; + } + } + + /** + * Renders the in-progress state + * @param {HTMLElement} statsElement - Stats element + */ + renderInProgressState(statsElement) { + // Hide stats during gameplay + statsElement.style.display = 'none'; + } + + renderCompletedPuzzle(puzzleDisplay, inputContainer, keystrokeStats) { + // Validate required elements + if (!puzzleDisplay || !inputContainer || !keystrokeStats) { + console.error('Missing required elements for completion rendering'); + return; + } + + // Hide the input container (for both desktop and mobile) + inputContainer.style.display = 'none'; + + // Set up the completed state + this.setupCompletionState(puzzleDisplay); + + // Use the puzzle display state; if the completion text isn't already present, prepend it + const hasCompletionText = this.state.displayState.includes(this.PUZZLE_DATA.completionText); + const finalDisplayText = hasCompletionText + ? this.state.displayState + : `<strong>${this.PUZZLE_DATA.completionText}</strong>\n\n${this.state.displayState}`; + puzzleDisplay.innerHTML = finalDisplayText; + + // Remove any existing completion message to avoid duplicates + const existingCompletionMessage = this.root.querySelector('.completion-message'); + if (existingCompletionMessage) { + existingCompletionMessage.remove(); + } + + // Find the puzzle-content container + const puzzleContent = this.root.querySelector('.puzzle-content'); + if (!puzzleContent) { + console.error('Could not find puzzle-content container'); + return; + } + + // Create the completion message + const completionMessageWrapper = document.createElement('div'); + completionMessageWrapper.className = 'completion-message'; + completionMessageWrapper.innerHTML = this.generateCompletionMessage(); + + // Insert after puzzle display + const insertAfterElement = puzzleDisplay; + if (insertAfterElement && insertAfterElement.nextSibling) { + puzzleContent.insertBefore(completionMessageWrapper, insertAfterElement.nextSibling); + } else { + puzzleContent.appendChild(completionMessageWrapper); + } + + // Show stats container and render completion stats UI + keystrokeStats.style.display = 'block'; + + // Calculate stats with wrong guesses + const efficiency = ((this.state.minimumKeystrokes / this.state.totalKeystrokes) * 100); + const stats = this.rankCalculator.getDetailedStats( + efficiency, + this.state.peekedClues.size, + this.state.megaPeekedClues.size, + this.state.wrongGuesses // Include wrong guesses + ); + + // Render stats + keystrokeStats.querySelector('.stats-content').innerHTML = ` + ${this.generateRankDisplay(stats)} + ${this.generateStatItems(stats)} + `; + + this.attachCompletionHandlers(keystrokeStats); + + // Scroll to top + setTimeout(() => { + window.scrollTo({ + top: 0, + behavior: 'smooth' + }); + this.setupDatePicker(); + }, 100); + } + + renderCompletionStats(keystrokeStats) { + const efficiency = ((this.state.minimumKeystrokes / this.state.totalKeystrokes) * 100); + const stats = this.rankCalculator.getDetailedStats( + efficiency, + this.state.peekedClues.size, + this.state.megaPeekedClues.size + ); + + keystrokeStats.style.display = 'block'; + keystrokeStats.querySelector('.stats-content').innerHTML = ` + ${this.generateRankDisplay(stats)} + ${this.generateStatItems(stats)} + `; + + this.attachCompletionHandlers(keystrokeStats); + } + + /** + * Apply highlighting to active clues with first letter peek + * @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 to process them from end to beginning + activeClues.sort((a, b) => b.start - a.start); + + let highlightedText = cleanText; + + activeClues.forEach(clue => { + const expressionText = clue.expression.trim(); + + // Create a precise slice by using the exact indices + 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 - leave as is + highlightedText = before + clueText + after; + } else { + // Active or hint mode clue + if (this.state.hintModeClues.has(expressionText)) { + const solution = this.PUZZLE_DATA.solutions[expressionText]; + // Show the first letter hint + if (solution) { + const firstLetter = solution.charAt(0).toUpperCase(); + // Use exact position for end bracket to avoid issues with special characters + const lastBracketPos = clueText.lastIndexOf(']'); + if (lastBracketPos !== -1) { + clueText = clueText.substring(0, lastBracketPos) + ` (${firstLetter})]`; + } + } + } + + // Use more robust string manipulation to ensure the entire expression is highlighted + highlightedText = before + `<span class="active-clue">${clueText}</span>` + after; + } + }); + + return highlightedText; + } + + /*************** + HTML generation + ****************/ + + generateLoadingHTML() { + return ` + <div class="loading"> + Loading today's puzzle... + </div> + `; + } + + /** + * Generates HTML for the input container based on current mode + * @returns {string} HTML markup + */ + generateInputContainerHTML() { + if (this.useCustomKeyboard) { + if (this.inputMode === 'submit') { + 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;" + > + [enter] + </button> + </div> + <div class="custom-keyboard"> + ${this.generateKeyboardButtonsHTML()} + </div> + </div> + `; + } else { + // Classic mode (auto-snap) + return ` + <div class="input-container"> + <div class="message"></div> + <div class="custom-input"> + <span class="placeholder">start typing answers...</span> + </div> + <div class="custom-keyboard"> + ${this.generateKeyboardButtonsHTML()} + </div> + </div> + `; + } + } else { + // Desktop + if (this.inputMode === 'submit') { + return ` + <div class="input-container"> + <div class="input-submit-wrapper" style="display: flex; gap: 0.5rem;"> + <input + type="text" + placeholder="type any answer..." + name="silly-joke" + class="answer-input" + autocomplete="off" + autocapitalize="off" + > + <button + class="submit-answer" + > + [enter] + </button> + </div> + <div class="message"></div> + </div> + `; + } else { + // Classic mode (auto-snap) + return ` + <div class="input-container"> + <input type="text" placeholder="Start typing answers..." name="silly-joke" class="answer-input" autocomplete="off" autocapitalize="off"> + <div class="message"></div> + </div> + `; + } + } + } + +// Modified generateKeyboardButtonsHTML method +// Modified generateKeyboardButtonsHTML method +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>'; + }); + + html += '</div>'; + return 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> + `; +} + + generateKeyboardKeyHTML(key) { + return ` + <button class="keyboard-key" data-key="${key}"> + ${key} + </button> + `; + } + + /** + * Generates initial HTML structure for the game + * @returns {string} HTML markup + */ + generateInitialHTML() { + const displayDate = this.currentPuzzleDate || this.getNYCDate(); + + return ` + <div class="puzzle-container"> + ${this.generateHeaderHTML(displayDate)} + <div class="puzzle-content"> + ${this.generatePuzzleDisplayHTML()} + ${this.generateInputContainerHTML()} + ${this.generateStatsContainerHTML()} + ${this.generateSolvedExpressionsHTML()} + </div> + </div> + `; + } + + generateHeaderHTML(displayDate) { + return ` + <div class="puzzle-header"> + <button class="info-button">!</button> + <h1>[Bracket Village]</h1> + <button class="help-button">?</button> + <div class="nav-container"> + <button class="nav-button prev" ${!this.isMobile && 'disabled'}>←</button> + <div class="puzzle-date">${this.formatNYCDate(displayDate)}</div> + <button class="nav-button next" ${!this.isMobile && 'disabled'}>→</button> + </div> + </div> + `; + } + + generatePuzzleDisplayHTML() { + return `<div class="puzzle-display"></div>`; + } + + generateStatsContainerHTML() { + return ` + <div class="keystroke-stats" style="display: none;"> + <div class="stats-content"></div> + <div class="share-buttons"> + <button class="share-button copy">Copy Results</button> + <button class="share-button sms">Share via Text</button> + <button class="share-button reset">Reset Puzzle</button> + </div> + <div class="share-message" style="display: none;">Results copied to clipboard!</div> + </div> + `; + } + + _generateSolvedExpressionItemHTML(expression, solution) { + return ` + <div class="expression-item"> + <span class="expression">[${expression}]</span> = + <span class="solution">${solution}</span> + </div> + `; + } + + generateSolvedExpressionsHTML() { + // Convert Set to Array and reverse it to get most recent first + const expressionsList = Array.from(this.state.solvedExpressions) + .reverse() + .map(expression => this._generateSolvedExpressionItemHTML( + expression, + this.PUZZLE_DATA.solutions[expression] + )) + .join(''); + + return ` + <div class="solved-expressions"> + <h3></h3> + <div class="expressions-list"> + ${expressionsList} + </div> + </div> + `; + } + + generateCompletionMessage() { + return ` + <div class="message success completion-message"> + <span><strong>** Puzzle Solved! **</strong></span><br> + <a href="${this.PUZZLE_DATA.completionURL}" + target="_blank" + rel="noopener noreferrer" + class="completion-url"> + ${this.PUZZLE_DATA.completionURL} + </a> + </div> + `; + } + + generateRankDisplay(stats) { + const margin = this.isMobile ? '0.5rem 0' : '1rem 0'; + const padding = this.isMobile ? '1rem' : '1.5rem'; + + // Custom messages for certain ranks + let nextRankMessage = ''; + + if (stats.rank === 'Puppet Master') { + nextRankMessage = '<br><span class="next-rank">now I am become Death, the destroyer of worlds</span>'; + } else if (stats.rank === 'Kingmaker') { + nextRankMessage = '<br><span class="next-rank">ah finally the very top</span>'; + } else if (stats.rank === 'Mayor') { + nextRankMessage = '<br><span class="next-rank">nobody\'s my boss, right?</span>'; + } else if (stats.rank === 'Power Broker') { + nextRankMessage = '<br><span class="next-rank">this has got to be it...</span>'; + } else if (stats.nextRankName) { + nextRankMessage = `<br><span class="next-rank">Almost: ${stats.nextRankName} (${Math.ceil(stats.pointsToNextRank)} points needed)</span>`; + } + + return ` + <div class="rank-display" data-rank="${stats.rank}"> + You are ${(stats.rank === 'Chief of Police' || stats.rank === 'Mayor') ? 'the' : 'a'} Bracket Village<br> + ${stats.rankEmoji} <b>${stats.rank}</b> ${stats.rankEmoji} + ${nextRankMessage} + <div class="share-hint"> + ${this.isMobile ? '(tap to share via text)' : '(click to copy results)'} + </div> + </div> + `; + } + + + /** + * Updated generateStatItems method to show different stats based on mode + */ + generateStatItems(stats) { + // Generate progress bar for score + const segments = 10; + const filledSegments = Math.round((stats.finalScore / 100) * segments); + const emptySegments = segments - filledSegments; + + // Use purple squares for Puppet Master, otherwise use color based on score + let segmentEmoji; + if (stats.rank === 'Puppet Master') { + segmentEmoji = '\u{1F7EA}'; // Purple Square emoji + } else { + segmentEmoji = stats.finalScore < 25 ? '\u{1F7E5}' : // Red + stats.finalScore < 75 ? '\u{1F7E8}' : // Yellow + '\u{1F7E9}'; // Green + } + + const progressBar = segmentEmoji.repeat(filledSegments) + '\u{2B1C}'.repeat(emptySegments); + + // Score bar HTML + const scoreBarHTML = ` + <div class="score-bar"> + <div class="score-value">Score: ${stats.finalScore.toFixed(1)}</div> + <div class="progress-bar">${progressBar}</div> + ${this.inputMode === 'classic' ? '<div class="hard-mode-indicator" style="font-size: 1rem; text-align: center; margin-top: 5px; font-weight: bold;">\u{2620}\u{FE0F} hard mode!</div>' : ''} + </div> + `; + + // For classic mode (keystroke-based) + if (this.inputMode === 'classic') { + return ` + ${scoreBarHTML} + <div class="stat-items"> + <div class="stat-item">Total keystrokes: ${this.state.totalKeystrokes}</div> + <div class="stat-item">Minimum keystrokes needed: ${this.state.minimumKeystrokes}</div> + <div class="stat-item">Excess keystrokes: ${this.state.totalKeystrokes - this.state.minimumKeystrokes}</div> + <div class="stat-item">\u{1F440} Clues peeked: ${this.state.peekedClues.size}</div> + <div class="stat-item">\u{1F6DF} Answers revealed: ${this.state.megaPeekedClues.size}</div> + <div class="stat-item"><b>Score breakdown</b> + <br>Base score (efficiency): ${stats.baseScore.toFixed(1)} + <br>Peek penalty: -${stats.peekPenalty.toFixed(1)} + ${stats.megaPeekPenalty > 0 ? `<br>Reveal penalty: -${stats.megaPeekPenalty.toFixed(1)}` : ''} + <br>Final score: ${stats.finalScore.toFixed(1)} + </div> + </div> + `; + } + // For submit mode (wrong-guess-based) + else { + return ` + ${scoreBarHTML} + <div class="stat-items"> + <div class="stat-item">\u{274C} Wrong guesses: ${this.state.wrongGuesses || 0}</div> + <div class="stat-item">\u{1F440} Clues peeked: ${this.state.peekedClues.size}</div> + <div class="stat-item">\u{1F6DF} Answers revealed: ${this.state.megaPeekedClues.size}</div> + <div class="stat-item"><b>Score breakdown</b> + <br>Base score: ${stats.baseScore.toFixed(1)} + ${stats.wrongGuessPenalty > 0 ? `<br>Wrong guess penalty: -${stats.wrongGuessPenalty.toFixed(1)}` : ''} + <br>Peek penalty: -${stats.peekPenalty.toFixed(1)} + ${stats.megaPeekPenalty > 0 ? `<br>Reveal penalty: -${stats.megaPeekPenalty.toFixed(1)}` : ''} + <br>Final score: ${stats.finalScore.toFixed(1)} + </div> + <div class="stat-item">Total keystrokes: ${this.state.totalKeystrokes}</div> + <div class="stat-item">Minimum keystrokes needed: ${this.state.minimumKeystrokes}</div> + <div class="stat-item">Excess keystrokes: ${this.state.totalKeystrokes - this.state.minimumKeystrokes}</div> + </div> + `; + } + } + + /************************** + UI setup and configuration + ***************************/ + + /** + * Sets up puzzle display and help system + */ + setupPuzzleDisplay() { + + const puzzleDisplay = this.root.querySelector('.puzzle-display'); + if (!puzzleDisplay) { + console.error('Puzzle display element not found'); + return; + } + + this.configurePuzzleDisplayLayout(puzzleDisplay); + this.setupHelpSystem(); + this.setupInfoSystem(); + this.setupClueClickHandlers(puzzleDisplay); + + // Add header click handler + const header = this.root.querySelector('.puzzle-header h1'); + if (header) { + header.addEventListener('click', async () => { + const todayDate = this.getNYCDate(); + if (this.currentPuzzleDate !== todayDate) { + await this.navigateToPuzzle(todayDate); + } + }); + } + } + + /** + * Configures puzzle display layout based on device + * @param {HTMLElement} puzzleDisplay - The puzzle display element + */ + configurePuzzleDisplayLayout(puzzleDisplay) { + const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent); + puzzleDisplay.classList.add(isMobile ? 'puzzle-display-mobile' : 'puzzle-display-desktop'); + } + + + /** + * Checks if the user has seen the current info version + * @returns {boolean} Whether the user has seen the current info + */ + hasSeenInfo() { + // Check if the user has seen the current version of the info + return localStorage.getItem('bracketCityInfoSeen') === this.INFO_VERSION; + } + + /** + * Marks the current info version as seen + */ + markInfoAsSeen() { + // Store the current version instead of just 'true' + localStorage.setItem('bracketCityInfoSeen', this.INFO_VERSION); + } + + /** + * Sets up the info system with an inline toggle switch + */ + setupInfoSystem() { + // Define the current info version - increment this to reset for all users + this.INFO_VERSION = '4'; // Increment version for the new layout + + const infoButton = this.root.querySelector('.info-button'); + if (!infoButton) return; + + // Check and apply the initial button state + if (!this.hasSeenInfo()) { + infoButton.classList.add('unseen-info'); + } + + // CSS for the iOS-style toggle + const toggleStyle = ` + <style> + /* iOS Toggle Switch */ + .inline-toggle { + position: relative; + display: inline-block; + width: 36px; + height: 20px; + vertical-align: middle; + margin: 0 6px; + } + + .inline-toggle input { + opacity: 0; + width: 0; + height: 0; + } + + .toggle-slider { + position: absolute; + cursor: pointer; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: #ccc; + transition: .3s; + border-radius: 34px; + } + + .toggle-slider:before { + position: absolute; + content: ""; + height: 16px; + width: 16px; + left: 2px; + bottom: 2px; + background-color: white; + transition: .2s; + border-radius: 50%; + box-shadow: 0 1px 3px rgba(0,0,0,0.2); + } + + input:checked + .toggle-slider { + background-color: #2563eb; + } + + input:disabled + .toggle-slider { + background-color: #d1d5db; + cursor: not-allowed; + } + + input:checked:disabled + .toggle-slider { + background-color: #93c5fd; + } + + input:checked + .toggle-slider:before { + transform: translateX(16px); + } + + .mode-disabled-message { + display: none; + } + + .toggle-disabled .mode-disabled-message { + display: inline; + } + </style> + `; + + // Build the info content with the toggle at the top + + // old news + //<div style="margin: 0.9em 0; font-size: 0.85em;">* clicking a <mark style="background-color: #fff9c4;">clue</mark> lets you peek at the answer's first letter</div> + //<div style="margin: 0.9em 0; font-size: 0.85em;">* clicking <span class='help-icon'>?</span> takes you to an interactive tutorial</div> + + const infoContent = ` + ${toggleStyle} + <div id="toggle-container" style="text-align: center; margin: 0.4em 0 1em 0;"> + <div style="display: flex; flex-direction: column; align-items: center; gap: 0;"> + <div style="display: flex; align-items: center; justify-content: center; gap: 6px;"> + <span style="font-weight: 500; font-size: 0.95em;">hard mode</span> + <label class="inline-toggle" onclick="event.stopPropagation();" style="margin: 0 0 0 2px;"> + <input type="checkbox" id="mode-toggle" onclick="event.stopPropagation();"> + <span class="toggle-slider"></span> + </label> + </div> + <div style="font-size: 0.7em; line-height: 1; margin-top: 1px; display: flex; justify-content: center; align-items: center; flex-wrap: wrap; gap: 4px;"> + <span style="font-style: italic; color: #6b7280;">no submit button & every keystroke counts!</span> + <span class="mode-disabled-message" style="color: #ef4444; font-weight: 500;"> + (can't change once a puzzle started) + </span> + </div> + </div> + </div> + + <div style="margin: 0.9em 0; font-size: 0.85em;">* there is now a <mark style="background-color:rgba(255,255,0,0.2)">date picker</mark> - just click the date in the header and browse the archive</div> + `; + + infoButton.addEventListener('click', (e) => { + e.stopPropagation(); + + // Mark as seen when clicked + if (!this.hasSeenInfo()) { + this.markInfoAsSeen(); + infoButton.classList.remove('unseen-info'); + } + + this.toggleHelp(infoContent); + + // Add event listener to the toggle switch + setTimeout(() => { + const modeToggle = document.getElementById('mode-toggle'); + const toggleContainer = document.getElementById('toggle-container'); + + if (!modeToggle || !toggleContainer) return; + + // Always evaluate current puzzle state + const hasPuzzleStarted = this.isPuzzleStarted(); + + // Set the checked state to match the current inputMode + modeToggle.checked = this.inputMode === 'classic'; + + // Update the disabled state and container class based on current puzzle state + modeToggle.disabled = hasPuzzleStarted; + toggleContainer.className = hasPuzzleStarted ? 'toggle-disabled' : ''; + + if (!modeToggle.disabled) { + modeToggle.addEventListener('change', (e) => { + e.stopPropagation(); + + // Double-check puzzle state before changing mode + if (this.isPuzzleStarted()) { + // If puzzle has been started since info panel opened, disable toggle and show message + modeToggle.disabled = true; + toggleContainer.className = 'toggle-disabled'; + // Reset toggle to match current mode + modeToggle.checked = this.inputMode === 'classic'; + return; + } + + // Update mode based on toggle state + // ON (checked) = classic mode, OFF (unchecked) = submit mode + const newMode = modeToggle.checked ? 'classic' : 'submit'; + this.setInputMode(newMode); + }); + } + + // Make sure all interactive elements stop propagation + toggleContainer.addEventListener('click', (e) => { + // Only stop propagation if clicking on the toggle itself + if (e.target === modeToggle || e.target.closest('.inline-toggle')) { + e.stopPropagation(); + } + }); + + // Add MutationObserver to update toggle state when puzzle state changes + this.setupToggleStateObserver(modeToggle, toggleContainer, isPuzzleStarted); + }, 100); + }); + } + + /** + * Checks if the user is a new player by examining localStorage + * @returns {boolean} True if this appears to be a new player + */ + isNewPlayer() { + let count = 0; + + // Count the number of localStorage keys that start with 'bracketCity' + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key && key.startsWith('bracketPuzzle')) { + count++; + } + } + + // If more than one key is found, the player is not new + if (count > 1 || localStorage.getItem('tutorialSeen')) { + return false; + } + + // Otherwise, the player is considered new + return true; + } + + /** + * Sets up the help/tutorial system with special highlighting for new players + */ + setupHelpSystem() { + const helpButton = this.root.querySelector('.help-button'); + if (!helpButton) return; + + // Check if this is a new player to determine if we should highlight the tutorial button + if (this.isNewPlayer()) { + helpButton.classList.add('new-player-highlight'); + } + + helpButton.addEventListener('click', (e) => { + e.stopPropagation(); + + // Remove the highlighting when clicked + helpButton.classList.remove('new-player-highlight'); + + if (confirm('Would you like to start the tutorial?')) { + localStorage.setItem('tutorialSeen', 'true'); + this.saveState(); + window.location.href = 'tutorial'; + } + }); + } + + turnOffTutorialPulse() { + const helpButton = this.root.querySelector('.help-button'); + if (helpButton) { + helpButton.classList.remove('new-player-highlight'); + } + localStorage.setItem('tutorialSeen', 'true'); + } + + /** + * Toggles the help display with special handling for interactive elements + * @param {string} helpContent - The help content to display + */ + toggleHelp(helpContent) { + const puzzleDisplay = this.root.querySelector('.puzzle-display'); + if (!puzzleDisplay) return; + + // Check if the puzzle is completed + const isPuzzleCompleted = puzzleDisplay.classList.contains('completed'); + + // Get the info button + const infoButton = this.root.querySelector('.info-button'); + + // Store current puzzle state when help is shown + if (!this.state.helpVisible) { + // Opening help + this.updateGameState({ + helpVisible: true, + previousDisplay: puzzleDisplay.innerHTML + }); + + if (isPuzzleCompleted) { + // In completed state, just set content without scroll anchor + puzzleDisplay.innerHTML = helpContent; + // Don't auto-scroll in completed state + } else { + // Normal in-progress behavior with scroll anchor + puzzleDisplay.innerHTML = '<div id="help-scroll-anchor"></div>' + helpContent; + + // Scroll to anchor - works reliably on iOS Safari + const scrollAnchor = document.getElementById('help-scroll-anchor'); + if (scrollAnchor) { + scrollAnchor.scrollIntoView({block: 'start', behavior: 'auto'}); + } + } + + // Change the info button text from "!" to "X" + if (infoButton) { + infoButton.textContent = "X"; + } + + // Disable all inputs including submit button + this.disableInputsForHelp(); + + // Setup click handler for the puzzle display that closes help + // But don't close when clicking on interactive elements + puzzleDisplay.addEventListener('click', this.handleHelpClick = (event) => { + // Check if the click was on an interactive element + const isInteractiveElement = + event.target.tagName === 'INPUT' || + event.target.tagName === 'BUTTON' || + event.target.tagName === 'LABEL' || + event.target.tagName === 'A' || + event.target.classList.contains('toggle-container') || + event.target.classList.contains('toggle-labels') || + event.target.classList.contains('toggle-slider') || + event.target.closest('label') || + event.target.closest('a') || + event.target.closest('.toggle-container'); + + // Only close help if it wasn't an interactive element + if (!isInteractiveElement) { + this.closeHelp(); + } + }); + + } else { + this.closeHelp(); + } + } + + /** + * Closes the help panel and restores previous state + */ + closeHelp() { + const puzzleDisplay = this.root.querySelector('.puzzle-display'); + if (!puzzleDisplay) return; + + // Change the info button text back from "X" to "!" + const infoButton = this.root.querySelector('.info-button'); + if (infoButton) { + infoButton.textContent = "!"; + } + + // Remove the click handler to prevent memory leaks + if (this.handleHelpClick) { + puzzleDisplay.removeEventListener('click', this.handleHelpClick); + this.handleHelpClick = null; + } + + // Closing help - restore previous state and re-render + this.updateGameState({ + helpVisible: false, + previousDisplay: null + }); + + // Re-render the entire puzzle state + this.render(); + + // Re-enable input + const desktopInput = this.root.querySelector('.answer-input'); + if (desktopInput) { + desktopInput.disabled = false; + } + + // Re-enable submit button (desktop) + const submitButton = this.root.querySelector('.submit-answer'); + if (submitButton) { + submitButton.disabled = false; + submitButton.style.opacity = '1'; + submitButton.style.pointerEvents = 'auto'; + } + + // For mobile, re-enable keyboard and input + if (this.isMobile) { + // Restore custom input appearance + const customInput = this.root.querySelector('.custom-input'); + if (customInput) { + customInput.style.opacity = '1'; + customInput.style.pointerEvents = 'auto'; + } + + // Re-enable all keyboard keys + const keyboardKeys = this.root.querySelectorAll('.keyboard-key'); + keyboardKeys.forEach(key => { + key.disabled = false; + key.style.opacity = '1'; + key.style.pointerEvents = 'auto'; + }); + + // Re-enable mobile submit button + const mobileSubmitButton = this.root.querySelector('.mobile-submit-button'); + if (mobileSubmitButton) { + mobileSubmitButton.disabled = false; + mobileSubmitButton.style.opacity = '1'; + mobileSubmitButton.style.pointerEvents = 'auto'; + } + } + + // Restore focus when closing help (but only if not disabled) + if (desktopInput && !desktopInput.disabled) { + desktopInput.focus(); + } + } + + /** + * Sets up click handlers for puzzle clues + * @param {HTMLElement} puzzleDisplay - The puzzle display element + */ + setupClueClickHandlers(puzzleDisplay) { + puzzleDisplay.addEventListener('click', (event) => { + // If help is showing, check if we clicked on a link before closing + if (this.state.helpVisible) { + // Check if the click was on a link (or its descendants) + const isLinkClick = event.target.tagName === 'A' || + event.target.closest('a'); + + // Only close help if it wasn't a link click + if (!isLinkClick) { + this.toggleHelp(); + } + return; + } + + 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(); + } + } + }); + } + + setupCompletionState(puzzleDisplay) { + puzzleDisplay.classList.add('completed'); + + // scroll to bottom fix + document.body.style.overflow = 'auto'; + + if (this.isMobile) { + // Reduce bottom padding of puzzle display + puzzleDisplay.style.paddingBottom = '0.5rem'; + + // Adjust puzzle container styles for mobile + const container = this.root.querySelector('.puzzle-container'); + if (container) { + Object.assign(container.style, { + position: 'relative', + height: '100vh', + overflowY: 'auto', + display: 'block' + }); + } + + // Reduce top margin and padding in the content area to decrease vertical space + const content = this.root.querySelector('.puzzle-content'); + if (content) { + Object.assign(content.style, { + position: 'relative', + top: '1.3rem', + height: 'auto', + marginTop: '60px', // reduced from 85px + padding: '0.5rem', + paddingBottom: '1rem', + overflowY: 'visible' + }); + } + } + } + + attachCompletionHandlers(keystrokeStats) { + // Rank display click handler + const rankDisplay = keystrokeStats.querySelector('.rank-display'); + if (rankDisplay) { + rankDisplay.addEventListener('click', () => { + if (this.isMobile) { + this.shareSMS(); + } else { + this.shareResults(); + } + }); + } + } + + setupShareButtons() { + const copyButton = this.root.querySelector('.share-button.copy'); + const smsButton = this.root.querySelector('.share-button.sms'); + const resetButton = this.root.querySelector('.share-button.reset'); + + copyButton.addEventListener('click', () => this.shareResults()); + + if (!/Android|iPhone|iPad|iPod/i.test(navigator.userAgent)) { + smsButton.style.display = 'none'; + } + smsButton.addEventListener('click', () => this.shareSMS()); + resetButton.addEventListener('click', () => this.resetPuzzle()); + + } + + /************************** + Message system + ***************************/ + + /** + * Updates the game message with proper state management + * @param {string} text - Message text to display + * @param {string} type - Message type ('success' or 'error') + */ + showMessage(text, type = 'success') { + const messageEl = this.root.querySelector('.message'); + if (!messageEl) return; + + // Update state + this.updateGameState({ + message: text, + messageType: type + }); + + // Update message element + messageEl.textContent = text; + messageEl.className = `message ${type}`; + messageEl.style.display = 'block'; // Ensure message is visible + + // Auto-clear ALL messages (both success and error) + setTimeout(() => { + // Only clear if this message is still showing + if (this.state.message === text) { + this.updateGameState({ + message: '', + messageType: null + }); + messageEl.textContent = ''; + messageEl.className = 'message'; + messageEl.style.display = 'none'; + } + }, 1500); + } + + /** + * Shows an error message with special handling + * @param {string} message - Error message to display + */ + showErrorMessage(message) { + const messageEl = this.root.querySelector('.message') || document.createElement('div'); + + // If we need to create a new message element + if (!messageEl.parentNode) { + messageEl.className = 'message'; + this.root.prepend(messageEl); + } + + // Update state to track error + this.updateGameState({ + message, + messageType: 'error', + lastError: { + message, + timestamp: Date.now() + } + }); + } + + + /************************** + Share system + ***************************/ + + /** + * Modified generateShareText to only display mode when in classic/hard mode + */ + generateShareText() { + const puzzleDate = this.formatNYCDate(this.currentPuzzleDate); + const efficiency = ((this.state.minimumKeystrokes / this.state.totalKeystrokes) * 100); + + // Calculate rank and stats + const stats = this.rankCalculator.getDetailedStats( + efficiency, + this.state.peekedClues.size, + this.state.megaPeekedClues.size, + this.state.wrongGuesses || 0 + ); + + // Generate progress bar + const segments = 10; + const filledSegments = Math.round((stats.finalScore / 100) * segments); + const emptySegments = segments - filledSegments; + + // Use purple squares for Puppet Master, otherwise use color based on score + let segmentEmoji; + if (stats.rank === "Puppet Master") { + segmentEmoji = '\u{1F7EA}'; // Purple Square emoji + } else { + segmentEmoji = stats.finalScore < 25 ? '\u{1F7E5}' : // Red + stats.finalScore < 75 ? '\u{1F7E8}' : // Yellow + '\u{1F7E9}'; // Green + } + + const progressBar = segmentEmoji.repeat(filledSegments) + '\u{2B1C}'.repeat(emptySegments); + + // Build share text with specific line breaks to match desired layout + const shareItems = [ + '[Bracket Village]', + puzzleDate, + '', + 'https://user.4574.co.uk/bv', + '' + ]; + + // Only show "hard mode" message if in classic mode + if (this.inputMode === 'classic') { + shareItems.push('\u{2620}\u{FE0F} hard mode!'); + shareItems.push(''); // Add a line break between mode and rank + } + + // Add rank + shareItems.push(`Rank: ${stats.rankEmoji} (${stats.rank})`); + + // Only include mode-specific stats + // Replace it with this updated logic: + if (this.inputMode === 'classic' || stats.rank === 'Puppet Master') { + // Show keystroke stats in classic mode or for any Puppet Master + shareItems.push(`\u{1F3B9} Total Keystrokes: ${this.state.totalKeystrokes}`); + shareItems.push(`\u{1F3AF} Minimum Required: ${this.state.minimumKeystrokes}`); + } else { + // Submit mode stats + shareItems.push(`\u{274C} Wrong guesses: ${this.state.wrongGuesses || 0}`); + } + + // Add peek and reveal counts only if they exist + if (this.state.peekedClues.size > 0) { + shareItems.push(`\u{1F440} Peeks: ${this.state.peekedClues.size}`); + } + + if (this.state.megaPeekedClues.size > 0) { + shareItems.push(`\u{1F6DF} Answers Revealed: ${this.state.megaPeekedClues.size}`); + } + + // Remove the input mode indicator - we now only show it for classic mode above the rank + + // Add score and progress bar with an empty line before score + shareItems.push(''); + shareItems.push(`Total Score: ${stats.finalScore.toFixed(1)}`); + shareItems.push(progressBar); + + return shareItems.join('\n'); + } + + /** + * Handles sharing results via the user's preferred app using Web Share API + * Falls back to SMS URL scheme if Web Share API is not available + */ + shareSMS() { + const shareText = this.generateShareText(); + + // Check if Web Share API is available + if (navigator.share) { + navigator.share({ + title: 'Bracket Village Results', + text: shareText + }) + .then(() => { + console.log('Successfully shared results'); + }) + .catch((error) => { + console.error('Error sharing results:', error); + // Fall back to SMS URL scheme if sharing fails + this.fallbackToSMS(shareText); + }); + } else { + // Fall back to SMS URL scheme for older browsers + this.fallbackToSMS(shareText); + } + } + + /** + * Fallback method to use SMS URL scheme when Web Share API is not available + * @param {string} shareText - The text to share + */ + fallbackToSMS(shareText) { + const encodedText = encodeURIComponent(shareText); + + // Use correct SMS URL scheme format + window.location.href = `sms:?&body=${encodedText}`; + } + + /** + * Copies results to clipboard for desktop sharing + */ +shareResults() { + const shareText = this.generateShareText(); + + // Use navigator.clipboard API for modern browsers + if (navigator.clipboard) { + navigator.clipboard.writeText(shareText) + .then(() => { + // Show success message + const shareMessage = this.root.querySelector('.share-message'); + if (shareMessage) { + shareMessage.style.display = 'block'; + setTimeout(() => { + shareMessage.style.display = 'none'; + }, 2000); + } + }) + .catch(err => { + console.error('Failed to copy results: ', err); + // Show error or fallback to execCommand + this.fallbackCopyToClipboard(shareText); + }); + } else { + // Fallback for older browsers + this.fallbackCopyToClipboard(shareText); + } +} + +/** + * Fallback method to copy text to clipboard using execCommand + * @param {string} text - Text to copy + */ +fallbackCopyToClipboard(text) { + try { + const textArea = document.createElement('textarea'); + textArea.value = text; + textArea.style.position = 'fixed'; // Avoid scrolling to bottom + document.body.appendChild(textArea); + textArea.focus(); + textArea.select(); + + const successful = document.execCommand('copy'); + if (successful) { + // Show success message + const shareMessage = this.root.querySelector('.share-message'); + if (shareMessage) { + shareMessage.style.display = 'block'; + setTimeout(() => { + shareMessage.style.display = 'none'; + }, 2000); + } + } else { + console.error('execCommand failed'); + } + + document.body.removeChild(textArea); + } catch (err) { + console.error('Fallback copy method failed: ', err); + } +} + + /************************** + Utility methods + ***************************/ + + /** + * Resets the input container to initial state + * @param {HTMLElement} inputContainer - The input container element + */ + resetInputContainer(inputContainer) { + inputContainer.innerHTML = ` + <input type="text" placeholder="type any answer..." class="answer-input" autocomplete="off" autocapitalize="off"> + <div class="message"></div> + `; + // Re-setup input handlers for the new input element + this.setupInputHandlers(); + } + + /** + * Gets all required DOM elements + * @returns {Object} Object containing DOM elements + */ + getDOMElements() { + return { + puzzleDisplay: this.root.querySelector('.puzzle-display'), + expressionsList: this.root.querySelector('.expressions-list'), + inputContainer: this.root.querySelector('.input-container'), + keystrokeStats: this.root.querySelector('.keystroke-stats'), + message: this.root.querySelector('.message') + }; + } + + /** + * Validates that all required elements exist + * @param {Object} elements - DOM elements object + * @returns {boolean} Whether all required elements exist + */ + validateElements(elements) { + return Object.values(elements).every(element => element !== null); + } + + /** + * Checks if the puzzle is complete + * @returns {boolean} Whether the puzzle is complete + */ + + isPuzzleComplete() { + return !this.state.displayState.includes('['); + } + + cleanupEventListeners() { + const prevButton = this.root.querySelector('.nav-button.prev'); + const nextButton = this.root.querySelector('.nav-button.next'); + if (prevButton) prevButton.replaceWith(prevButton.cloneNode(true)); + if (nextButton) nextButton.replaceWith(nextButton.cloneNode(true)); + } + + /** + * Creates and displays an announcement modal + * @param {string} title - The title of the announcement + * @param {string} message - The message content (can include HTML) + * @param {string} buttonText - The text for the dismiss button + * @param {string} modalId - Unique identifier for the modal + * @returns {boolean} Whether the modal was shown + */ + showAnnouncementModal(title, message, buttonText = 'Got it', modalId) { + // Check if we should show this modal + if (!modalId || this.hasSeenAnnouncement(modalId)) { + return false; + } + + // Create modal HTML + const modalHTML = ` + <div class="announcement-modal-overlay" id="announcement-overlay"> + <div class="announcement-modal"> + <div class="announcement-modal-title">${title}</div> + <div class="announcement-modal-content">${message}</div> + <button class="announcement-modal-button" id="announcement-dismiss">${buttonText}</button> + </div> + </div> + `; + + // Append to the body + document.body.insertAdjacentHTML('beforeend', modalHTML); + + // Get DOM references + const overlay = document.getElementById('announcement-overlay'); + const dismissButton = document.getElementById('announcement-dismiss'); + + // Add event listeners + dismissButton.addEventListener('click', () => { + this.dismissAnnouncementModal(modalId); + }); + + // Close on outside click too + overlay.addEventListener('click', (e) => { + if (e.target === overlay) { + this.dismissAnnouncementModal(modalId); + } + }); + + // Show the modal with a small delay + setTimeout(() => { + overlay.classList.add('visible'); + }, 100); + + return true; + } + + /** + * Dismisses the announcement modal and marks it as seen + * @param {string} modalId - Unique identifier for the modal + */ + dismissAnnouncementModal(modalId) { + const overlay = document.getElementById('announcement-overlay'); + + if (!overlay) return; + + // Add fade-out animation + overlay.classList.remove('visible'); + + // Remove from DOM after animation completes + setTimeout(() => { + overlay.remove(); + + if (!this.isMobile) { + const inputElement = this.root.querySelector('.answer-input'); + if (inputElement && !inputElement.disabled) { + inputElement.focus(); + } + } + + }, 300); + + // Mark as seen + if (modalId) { + this.markAnnouncementAsSeen(modalId); + } + } + + /** + * Checks if the user has seen a specific announcement + * @param {string} modalId - Unique identifier for the modal + * @returns {boolean} Whether the announcement has been seen + */ + hasSeenAnnouncement(modalId) { + const storageKey = `bracketCityAnnouncement_${modalId}`; + return localStorage.getItem(storageKey) === 'seen'; + } + + /** + * Marks an announcement as seen in localStorage + * @param {string} modalId - Unique identifier for the modal + */ + markAnnouncementAsSeen(modalId) { + const storageKey = `bracketCityAnnouncement_${modalId}`; + localStorage.setItem(storageKey, 'seen'); + } + + /** + * Helper method to display an important announcement + * @param {Object} options - Announcement options + * @param {string} options.title - The title of the announcement + * @param {string} options.message - The announcement message + * @param {string} options.buttonText - Text for the dismiss button + * @param {string} options.id - Unique identifier for this announcement + * @returns {boolean} Whether the announcement was shown + */ + showImportantAnnouncement(options) { + const { + title = 'Important Announcement', + message = '', + buttonText = 'Got it', + id = 'default-announcement' + } = options; + + return this.showAnnouncementModal(title, message, buttonText, id); + } +} diff --git a/static/bv/index.html b/static/bv/index.html new file mode 100644 index 0000000..cecdf44 --- /dev/null +++ b/static/bv/index.html @@ -0,0 +1,30 @@ + +<!DOCTYPE html> +<html lang="en"> +<head> + <base href="/bv/"> + <link rel="stylesheet" href="bracket.css"> + <link rel="icon" type="image/png" href="bc-favicon.png"> + <meta name="viewport" content="width=device-width, initial-scale=1"> + <title>[Bracket Village]</title> + <meta name="description" content="Bracket Village is a nested clue puzzle game."> + + <!-- Open Graph meta tags --> + <meta property="og:title" content="Bracket Village" /> + <meta property="og:description" content="come visit bracket village" /> + <meta property="og:image" content="https://bracket.city/bc-postcard.jpg" /> + <meta property="og:url" content="https://bracket.city/" /> + <meta property="og:type" content="website" /> + <meta property="og:image:width" content="1200" /> + <meta property="og:image:height" content="800" /> + +</head> +<body> + <div id="puzzle-root"></div> + <script src="bracket.js"></script> + <script src="puzzleencoder.js"></script> + <script> + const puzzle = new BracketPuzzle(document.getElementById('puzzle-root')); + </script> +</body> +</html> diff --git a/static/bv/puzzleencoder.js b/static/bv/puzzleencoder.js new file mode 100644 index 0000000..fcba62c --- /dev/null +++ b/static/bv/puzzleencoder.js @@ -0,0 +1,83 @@ +// Utility functions for compact puzzle encoding/decoding +const PuzzleEncoder = { + // Simple substitution cipher using base64 + cipher: { + encode(str) { + return btoa(encodeURIComponent(str)).replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_'); + }, + decode(str) { + str = str.replace(/-/g, '+').replace(/_/g, '/'); + // Add back padding if needed + while (str.length % 4) str += '='; + try { + return decodeURIComponent(atob(str)); + } catch (e) { + console.error('Failed to decode:', e); + return null; + } + } + }, + + // Compress puzzle structure by replacing common patterns + compressPuzzle(puzzle) { + return puzzle + .replace(/\[\[/g, '<<') // Replace double brackets + .replace(/\]\]/g, '>>') // with angle brackets + .replace(/\[/g, '<') // Replace single brackets + .replace(/\]/g, '>') // with single angle brackets + .replace(/\s+/g, ' '); // Normalize spaces + }, + + // Decompress puzzle structure + decompressPuzzle(compressed) { + return compressed + .replace(/<<|〈〈/g, '[[') + .replace(/>>|〉〉/g, ']]') + .replace(/[<〈]/g, '[') + .replace(/[>〉]/g, ']'); + }, + + // Compress solutions map into compact format + compressSolutions(solutions) { + const pairs = Object.entries(solutions) + .map(([expr, sol]) => `${expr}:${sol}`) + .join('|'); + return pairs; + }, + + // Decompress solutions back into object + decompressSolutions(compressed) { + if (!compressed) return {}; + return Object.fromEntries( + compressed.split('|') + .map(pair => pair.split(':')) + .filter(([k, v]) => k && v) // Filter out any invalid pairs + ); + }, + + // Encode entire puzzle into compact URL-safe string + encodePuzzle(puzzleData) { + const compressed = { + p: this.compressPuzzle(puzzleData.initialPuzzle), + s: this.compressSolutions(puzzleData.solutions) + }; + return this.cipher.encode(JSON.stringify(compressed)); + }, + + // Decode puzzle from compact string + decodePuzzle(encoded) { + try { + const decoded = this.cipher.decode(encoded); + if (!decoded) return null; + + const compressed = JSON.parse(decoded); + return { + initialPuzzle: this.decompressPuzzle(compressed.p), + solutions: this.decompressSolutions(compressed.s) + }; + } catch (e) { + console.error('Failed to decode puzzle:', e); + return null; + } + } + };
\ No newline at end of file diff --git a/static/bv/tutorial/index.html b/static/bv/tutorial/index.html new file mode 100644 index 0000000..ac49d70 --- /dev/null +++ b/static/bv/tutorial/index.html @@ -0,0 +1,22 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <base href="/bv/tutorial/" /> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>Bracket Village</title> + <link rel="icon" type="image/png" href="../bc-favicon.png"> + <!-- Tutorial CSS --> + <link rel="stylesheet" href="tutorial.css"> +</head> +<body> + <!-- Main container for the game --> + <div id="bracket-city-container"></div> + + + <!-- Tutorial mode scripts --> + <script src="tutorial.js"></script> + <script src="tutorial-init.js"></script> + +</body> +</html> diff --git a/static/bv/tutorial/tutorial-init.js b/static/bv/tutorial/tutorial-init.js new file mode 100644 index 0000000..ba0cc73 --- /dev/null +++ b/static/bv/tutorial/tutorial-init.js @@ -0,0 +1,22 @@ +startTutorialMode(); + + // Helper function to manually start the tutorial mode + function startTutorialMode() { + const container = document.getElementById('bracket-city-container'); + + if (container) { + // Clear any existing content + container.innerHTML = ''; + + // Initialize the tutorial + const tutorial = new BracketCityTutorial(container); + + // Store the tutorial instance in case we need to access it later + window.bracketCityTutorial = tutorial; + + return tutorial; + } else { + console.error('Could not find container element for Bracket Village tutorial'); + return null; + } + } diff --git a/static/bv/tutorial/tutorial.css b/static/bv/tutorial/tutorial.css new file mode 100644 index 0000000..4f2aff0 --- /dev/null +++ b/static/bv/tutorial/tutorial.css @@ -0,0 +1,945 @@ +/* =================== + Root Variables and Resets + =================== */ + :root { + --main-font: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; + --mono-font: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + --highlight-color: #fefcbf; + } + + *, *::before, *::after { + box-sizing: border-box; + } + + /* =================== + Container Structure + =================== */ + .puzzle-container { + width: 100%; + margin: 0 auto; + border: 1px solid #e2e8f0; + border-radius: 0.5rem; + background: white; + box-shadow: 0 1px 3px rgba(0,0,0,0.1); + padding: 0; + display: flex; + flex-direction: column; + } + + @media (min-width: 1024px) { + .puzzle-container { + max-width: 42rem; + } + } + + /* =================== + Header Styles + =================== */ + /* Header Base Styles */ + .puzzle-header { + background: white; + border-bottom: 1px solid #e2e8f0; + box-sizing: border-box; + padding: 0.6rem; + position: relative; /* Desktop default */ + text-align: center; + z-index: 10; + } + + /* Mobile-specific header styles */ + @media (max-width: 640px) { + .puzzle-header { + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 1000; + padding: max(0.4rem, env(safe-area-inset-top)); + } + } + + /* Header title */ + .puzzle-header h1 { + margin: 0 0 0.5rem 0; + text-align: center; + font-size: clamp(2rem, 8vw, 3rem); + cursor: pointer; + font-family: var(--main-font); + font-variant: small-caps; + color: #1a202c; + font-weight: 600; + } + + @media (max-width: 640px) { + .puzzle-header h1 { + margin: 0; + font-size: clamp(1.8rem, 7vw, 2.8rem); + } + } + + /* Help button */ + .puzzle-header .exit-button { + position: absolute; + top: 10px; + right: 20px; + background: none; + border: 2px solid #333; + border-radius: 50%; + width: 2.4rem; + height: 2.4rem; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + font-weight: bold; + font-size: 1.2rem; + color: #333; + } + + @media (max-width: 640px) { + .puzzle-header .exit-button { + right: 10px; + } + } + + /* Navigation container */ + .puzzle-header .nav-container { + display: flex; + align-items: center; + justify-content: center; + gap: 1rem; + } + + /* Navigation buttons */ + .puzzle-header .nav-button { + padding: 4px 12px; + border: none; + border-radius: 4px; + background: none; + cursor: pointer; + font-weight: bold; + font-size: 1.5rem; + color: #333; + } + + .puzzle-header .nav-button:disabled { + opacity: 0.3; + cursor: default; + } + + /* Puzzle date */ + .puzzle-header .puzzle-date { + margin: 0; + font-size: 20px; + font-weight: 600; + font-family: var(--main-font); + font-variant: small-caps; + color: #1a202c; + } + + /* =================== + Main Content Area + =================== */ + /* New rules for loading state */ + + .loading { + text-align: center; + padding: 2rem; + } + + .puzzle-content { + padding: 0rem 1rem 1rem 1rem; + display: flex; + flex-direction: column; + gap: 0rem; + width: 100%; + } + + /* =================== + Puzzle Display + =================== */ + .puzzle-display { + width: 100%; + min-height: 40vh; + max-height: 60vh; + background: #f1f5f9; + border-radius: 0.5rem; + padding: 1.5rem; + font-family: var(--mono-font); + font-size: 24px; + line-height: 1.5; + overflow-y: auto; + word-break: keep-all; + overflow-wrap: break-word; + hyphens: none; + transition: all 0.3s ease; + } + + .puzzle-display.completed { + min-height: auto; + max-height: none; + height: auto; + } + + /* =================== + Active Clue & Mark Styles + =================== */ + .active-clue { + background-color: rgba(255, 255, 0, 0.2); + border-radius: 3px; + padding: 2px 4px; + margin: -2px -4px; + transition: background-color 0.3s ease; + cursor: pointer; + } + + .active-clue:hover { + background-color: rgba(255, 255, 0, 0.3); + } + + mark.solved { + background-color: rgba(0, 255, 0, 0.2); + border-radius: 3px; + padding: 2px 4px; + margin: -2px -4px; + } + + mark { + background-color: transparent; + color: inherit; + } + + /* =================== + Input Styles + =================== */ + .input-container { + width: 100%; + margin: 10px 0 0 0; + position: relative; + } + + .answer-input { + width: 100%; + padding: 16px; + font-size: 16px !important; + border: 1px solid #e2e8f0; + border-radius: 6px; + margin-bottom: 0px; + box-sizing: border-box; + font-family: var(--mono-font); + } + + /* =================== + Message Styles + =================== */ + .message { + padding: 0.5rem; + margin: 0.5rem 0; + border-radius: 0.375rem; + text-align: center; + font-family: var(--mono-font); + display: none; + } + + .message.success { + background-color: #c6f6d5; + color: #1a202c; + overflow-wrap: anywhere; + font-size: 1.2rem; + display: block; + padding: 20px; + line-height: 2rem; + } + + .message.success .completion-link { + color: #2563eb; + text-decoration: underline; + } + + .message.error { + background-color: #fee2e2; + color: #ef4444; + display: block; + } + + /* =================== + Completion Message + =================== */ + .completion-message { + margin: 0.5rem 0; + padding: 0rem; + border-radius: 0.375rem; + text-align: center; + font-family: var(--mono-font); + font-size: 1.2rem; + line-height: 1.5; + } + + /* =================== + Solved Expressions + =================== */ + .solved-expressions { + margin-top: 0rem; + } + + .solved-expressions h3 { + font-size: 1.2rem; + font-weight: 500; + margin-bottom: 0.75rem; + font-family: var(--main-font); + color: #1a202c; + } + + .expression-item { + background: #f1f5f9; + padding: 0.75rem; + border-radius: 0.375rem; + margin-bottom: 0.5rem; + font-size: 1.2rem; + font-family: var(--mono-font); + line-height: 1.5; + } + + .expression-item .solution { + font-weight: 600; + color: #1a202c; + } + + + /* =================== + Stats & Completion + =================== */ + .stats-content { + margin-top: 0rem; + } + + .stat-items { + margin: 0.5rem 0; + } + + .stat-item { + background: #f1f5f9; + padding: 0.75rem; + border-radius: 0.375rem; + margin-bottom: 0.5rem; + font-size: 1rem; + line-height: 1.5; + font-family: var(--main-font); + } + + /* =================== + Share Button Styles + =================== */ + .share-buttons { + margin-top: 1rem; + display: flex; + flex-direction: column; + gap: 0.5rem; + } + + .share-button { + width: 100%; + padding: 0.75rem; + font-size: 1rem; + font-family: var(--main-font); + background: #1a202c; + color: white; + border: none; + border-radius: 0.375rem; + cursor: pointer; + } + + .share-button.reset { + background-color: #ff4444; + } + + .share-button:hover { + background: #2d3748; + } + + .share-message { + font-family: var(--main-font); + color: #1a202c; + text-align: center; + padding: 0.5rem; + margin-top: 0.5rem; + background: var(--highlight-color); + border-radius: 0.375rem; + } + + /* =================== + Rank Display + =================== */ + .rank-display { + font-family: var(--mono-font); + position: relative; + overflow: hidden; + padding: 1rem; + text-align: center; + font-size: 1.2em; + font-weight: 500; + border-radius: 0.375rem; + margin: 0rem 0rem 1rem 0rem; + border: 1px solid rgba(255,255,255,0.4); + box-shadow: 0 2px 4px rgba(0,0,0,0.05); + cursor: pointer; + transition: transform 0.2s; + user-select: none; + } + + .rank-display:active { + transform: scale(0.95); + } + .rank-display .next-rank { + font-size: 0.8em; + font-weight: normal; + } + + .rank-display .share-hint { + font-size: 0.7em; + margin-top: 0.5em; + color: #666; + } + + /* Rank gradients */ + .rank-display[data-rank="Tourist"] { background: linear-gradient(135deg, #f0fdf4 0%, #6ee7b7 100%); } + .rank-display[data-rank="Commuter"] { background: linear-gradient(135deg, #fff7ed 0%, #fbd38d 100%); } + .rank-display[data-rank="Resident"] { background: linear-gradient(135deg, #f8fafc 0%, #cbd5e1 100%); } + .rank-display[data-rank="Council Member"] { background: linear-gradient(135deg, #fafaf9 0%, #d6d3d1 100%); } + .rank-display[data-rank="Chief of Police"] { background: linear-gradient(135deg, #f0f9ff 0%, #93c5fd 100%); } + .rank-display[data-rank="Mayor"] { background: linear-gradient(135deg, #fff1f2 0%, #fda4af 100%); } + .rank-display[data-rank="Power Broker"] { background: linear-gradient(135deg, #fefce8 0%, #92400e 100%); } + .rank-display[data-rank="Kingmaker"] { background: linear-gradient(135deg, #fffbeb 0%, #fcd34d 100%); } + .rank-display[data-rank="Puppet Master"] { background: linear-gradient(135deg, #faf5ff 0%, #d8b4fe 100%); } + + /* =================== + Shimmer Animation + =================== */ + @keyframes shimmer { + 0% { transform: translateX(-150%) rotate(45deg); } + 100% { transform: translateX(150%) rotate(45deg); } + } + + .rank-display::after { + content: ''; + position: absolute; + top: -50%; + left: -100%; + width: 300%; + height: 200%; + background: linear-gradient( + to bottom right, + rgba(255,255,255,0) 0%, + rgba(255,255,255,0.3) 50%, + rgba(255,255,255,0) 100% + ); + transform: rotate(45deg); + animation: shimmer 4s infinite linear; + pointer-events: none; + } + + /* =================== + Mobile Styles + =================== */ + @media (max-width: 640px) { + body { + margin: 0; + padding: 0; + height: 100vh; + font-size: 14px; + overflow: hidden; + } + + .message.success { + background-color: #c6f6d5; + color: #1a202c; + overflow-wrap: anywhere; + font-size: 1rem; + display: block; + padding: 10px; + line-height: 1.4rem; + margin-top: 0.2rem; + } + + /* Mobile container setup */ + .puzzle-container { + height: 100vh; + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + display: flex; + flex-direction: column; + } + + /* Mobile content area */ + .puzzle-content { + position: absolute; + top: 70px; + left: 0; + right: 0; + bottom: 0; + overflow-y: auto; + -webkit-overflow-scrolling: touch; + padding: 0; + gap: 0rem; + } + + @font-face { + font-family: 'CustomMono'; + /* Use ui-monospace for every character except underscore */ + src: local("ui-monospace"); + /* All Unicode except underscore (U+005F) */ + unicode-range: U+0000-005E, U+0060-10FFFF; + } + + @font-face { + font-family: 'CustomMono'; + /* Use Menlo for underscore only */ + src: local("Menlo"); + unicode-range: U+005F; + } + + .puzzle-display { + height: 155px; + padding: 0.5rem 1rem 1rem 1rem; + margin: 0; + font-size: 1.2rem; + background: #f1f5f9; + padding-bottom: 200px; + font-family: 'CustomMono', ui-monospace; + } + + .puzzle-display.completed { + padding-bottom: 1rem !important; + } + + + .rank-display { + margin: 0rem 0.5rem 1rem 0.5rem; + } + + + /* Mobile input container */ + .input-container { + position: fixed; + bottom: 0; + left: 0; + right: 0; + background: white; + z-index: 10; + width: 100%; + border-top: 1px solid #e2e8f0; + box-shadow: 0 -2px 6px rgba(0,0,0,0.1); + padding: 0.5rem calc(0.5rem + env(safe-area-inset-left)) calc(0.5rem + env(safe-area-inset-bottom)) calc(0.5rem + env(safe-area-inset-right)); + box-sizing: border-box; + } + + .custom-input { + border: 1px solid #e2e8f0; + padding: 0.63rem; + border-radius: 6px; + margin-bottom: 0.125rem; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 1.2rem; + min-height: 2.4rem; + background: white; + width: 100%; + color: #000; + } + + .placeholder { + color: #9ca3af; + } + + /* Mobile keyboard */ + .custom-keyboard { + width: 100%; + padding: .25rem 0 0 0; + box-sizing: border-box; + background-color: white; + touch-action: none; + } + + .keyboard-row { + display: grid; + grid-template-columns: repeat(10, 1fr); + gap: 0.02rem; + margin-bottom: 0.125rem; + justify-content: center; + width: 100%; + } + + .keyboard-key { + height: 3.3rem; + min-height: 2.2rem; + display: flex; + align-items: center; + justify-content: center; + padding: 0.2rem; + margin: 0.1rem 0.15rem 0.1rem 0.15rem; + cursor: pointer; + color: black; + background-color:rgb(233, 233, 233); + box-sizing: border-box; + touch-action: manipulation; + text-align: center; + font-size: 1.125rem; + font-weight: 600; + border: 1px solid rgb(200, 200, 200); + user-select: none; + -webkit-tap-highlight-color: transparent; + -webkit-appearance: none !important; + appearance: none !important; + border-radius: 4px !important; + outline: none !important; + -webkit-tap-highlight-color: transparent !important; + transition: background-color 0.1s ease-out, + transform 0.1s ease-out; + -webkit-user-select: none; + user-select: none; + -webkit-transform: translateZ(0); + transform: translateZ(0); + } + + .keyboard-key:active { + background-color: #94a3b8; + transform: translateY(2px); + } + + .stat-item { + background: #f1f5f9; + padding: 0.75rem; + border-radius: 0.375rem; + margin-bottom: 0.5rem; + font-size: 1rem; + line-height: 1.5; + } + + /* Hide desktop elements */ + .bottom-controls, + .reset-button, + .solved-expressions, + .keystroke-stats, + .share-button.copy { + display: none; + } + + /* Mobile completion state */ + .puzzle-container.completed .puzzle-content { + position: static; + height: auto; + overflow: visible; + margin-top: 85px; + padding: 1rem; + } + + .puzzle-container.completed { + position: static; + height: auto; + min-height: 100vh; + overflow-y: auto; + } + + .puzzle-display.completed .input-container { + display: none; + } + + .puzzle-container.completed .solved-expressions, + .puzzle-container.completed .keystroke-stats { + display: block; + margin: 1rem; + } + + .keystroke-stats { + margin-top: 0rem; + } + } + + /* =================== + iOS-specific Styles + =================== */ + @supports (-webkit-touch-callout: none) { + .keyboard-key:active { + background-color: #94a3b8 !important; + transform: translateY(2px) !important; + } + } + +/* Tutorial Mode Styles */ +.tutorial-container { + --tutorial-guidance-bg: #ffe8fa; + --tutorial-guidance-border: #4a6fa5; + --tutorial-highlight-border: #f6b73c; + --tutorial-completion-bg: #e9f7ef; + --tutorial-completion-border: #27ae60; + --tutorial-button-bg: #2980b9; + --tutorial-button-hover: #3498db; + } + + /* Tutorial Guidance */ + .tutorial-guidance { + background-color: #ffe8fa !important; + border-radius: 8px; + font-family: var(--main-font); + border-left: 4px solid #ab57a8; + border-right: 4px solid #ab57a8; + height: 130px !important; + overflow: hidden; + padding: 16px; + margin: 16px 0; + vertical-align: middle; + } + + .small-break { + min-height: 1rem; /* adjust as needed */ + } + + @media (max-width: 640px) { + .small-break { + min-height: 0.8rem; /* adjust as needed */ + } + } + + .tutorial-guidance.emerge { + animation: emerge 0.4s cubic-bezier(0.4, 0, 0.2, 1) forwards; + } + + @keyframes emerge { + 0% { + opacity: 0; + transform: scale(0.95); + } + 100% { + opacity: 1; + transform: scale(1); + } + } + + .tutorial-guidance.highlight { + background-color: #fff8e5; + border-left-color: #f6b73c; + } + + @media (max-width: 640px) { + .tutorial-guidance { + margin: 0.625rem 0.625rem 0.625rem 0.625rem !important; /* 10px */ + /*padding: 1rem 0.3125rem !important; /* 16px 5px */ + font-size: 0.9rem; + height: 140px !important; + vertical-align: middle; + } + } + + /* Hide guidance when puzzle is completed */ + .tutorial-guidance.completed, + .puzzle-display.completed ~ .tutorial-guidance { + /*display: none;*/ + } + + /* Tutorial Message */ + .tutorial-message { + font-size: 16px; + } + + .tutorial-message strong { + font-weight: 600; + } + + .tutorial-message a { + text-decoration: none; + border-bottom: 1px solid; + padding-bottom: 1px; + transition: color 0.2s; + } + + .tutorial-message a:hover { + opacity: 0.8; + } + + .tutorial-message .emoji { + font-size: 1.2em; + } + + @media (max-width: 640px) { + .tutorial-message { + font-size: 14px; + } + } + + /* Tutorial Puzzle Display */ + .tutorial-puzzle-display { + height: 155px !important; + min-height: 155px !important; + max-height: 155px !important; + overflow-y: auto; + background: #f1f5f9; + border-radius: 0.5rem; + padding: 1rem; + font-family: var(--mono-font); + line-height: 1.7; + word-break: keep-all; + overflow-wrap: break-word; + transition: none; + } + + .tutorial-puzzle-display.completed { + height: 155px !important; + min-height: 155px !important; + max-height: 155px !important; + } + + @media (max-width: 640px) { + .tutorial-puzzle-display { + height: 155px !important; + min-height: 155px !important; + max-height: 155px !important; + padding: 0.8rem; + } + } + + /* Tutorial Highlights and Animations */ + .spotlight { + background-color: #fff9c4; + box-shadow: 0 0 0 2px #fff9c4; + border-radius: 4px; + } + + .correct { + background-color: rgba(0, 255, 0, 0.25); + box-shadow: 0 0 0 2px rgba(0, 255, 0, 0.2); + border-radius: 4px; + } + + @keyframes pulse { + 0% { + box-shadow: 0 0 0 0 rgba(255, 193, 7, 0.7); + transform: scale(1); + } + 50% { + box-shadow: 0 0 0 6px rgba(255, 193, 7, 0); + transform: scale(1.05); + } + 100% { + box-shadow: 0 0 0 0 rgba(255, 193, 7, 0); + transform: scale(1); + } + } + + @keyframes tap-hint { + 0% { transform: scale(1); } + 50% { transform: scale(1.05); } + 100% { transform: scale(1); } + } + + .active-clue.tutorial-highlight { + box-shadow: 0 0 0 2px #ffc107; + animation: pulse 2.5s infinite; + background-color: rgba(255, 255, 0, 0.2); + font-weight: 500; + } + + .active-clue.tap-hint { + animation: tap-hint 2s infinite; + } + + /* Tutorial Completion */ + .tutorial-completion { + background-color: #e9f7ef; + padding: 20px; + border-radius: 8px; + margin: 10px 10px 0px 10px; + text-align: center; + border: 2px solid #27ae60; + font-family: var(--mono-font); + animation: fade-in 0.5s ease-in-out; + } + + .tutorial-completion-title { + font-size: 20px; + margin-bottom: 10px; + } + + .tutorial-completion-text { + font-size: 16px; + } + + .tutorial-completion-wrapper { + margin: 0px; + } + + @keyframes fade-in { + from { + opacity: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + transform: translateY(0); + } + } + + /* Play Game Button */ + #playGameButton { + margin-top: 15px; + padding: 10px 20px; + background-color: #2980b9; + color: white; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 16px; + transition: background-color 0.2s, transform 0.1s; + } + + #playGameButton:hover { + background-color: #3498db; + } + + #playGameButton:active { + transform: scale(0.98); + } + + /* First Letter Hint */ + .first-letter-hint { + font-weight: bold; + color: #0c63e4; + } + + /* Submit button and input wrapper */ + .input-submit-wrapper { + display: flex; + gap: 0.5rem; + width: 100%; + } + + .submit-answer { + padding: 0 1.5rem; + height: 60px; + background-color: #2563eb; + color: white; + border: none; + border-radius: 0.375rem; + cursor: pointer; + font-weight: 500; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + white-space: nowrap; + font-size: 1rem; + } + + .submit-answer:hover { + background-color: #1d4ed8; + } + + .submit-answer:active { + background-color: #1e40af; + transform: scale(0.98); + } + + .mobile-submit-button { + background-color: #2563eb !important; + color: white !important; + font-size: 1rem !important; + font-weight: 500 !important; + } 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, '"') + .replace(/'/g, '''); + + 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, '"') + .replace(/'/g, '''); + + 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 }; + } |