From 3e45908a94f417044f10f15dbc2cb50756f7c69d Mon Sep 17 00:00:00 2001 From: Nat Lasseter Date: Fri, 11 Apr 2025 10:06:40 +0100 Subject: [bracket] moved home --- static/bv/bc-favicon.png | Bin 15538 -> 0 bytes static/bv/bracket.css | 1286 ----------- static/bv/bracket.js | 4075 ----------------------------------- static/bv/index.html | 30 - static/bv/puzzleencoder.js | 83 - static/bv/tutorial/index.html | 22 - static/bv/tutorial/tutorial-init.js | 22 - static/bv/tutorial/tutorial.css | 945 -------- static/bv/tutorial/tutorial.js | 1011 --------- 9 files changed, 7474 deletions(-) delete mode 100644 static/bv/bc-favicon.png delete mode 100644 static/bv/bracket.css delete mode 100644 static/bv/bracket.js delete mode 100644 static/bv/index.html delete mode 100644 static/bv/puzzleencoder.js delete mode 100644 static/bv/tutorial/index.html delete mode 100644 static/bv/tutorial/tutorial-init.js delete mode 100644 static/bv/tutorial/tutorial.css delete mode 100644 static/bv/tutorial/tutorial.js (limited to 'static/bv') diff --git a/static/bv/bc-favicon.png b/static/bv/bc-favicon.png deleted file mode 100644 index dc5136a..0000000 Binary files a/static/bv/bc-favicon.png and /dev/null differ diff --git a/static/bv/bracket.css b/static/bv/bracket.css deleted file mode 100644 index ab0285f..0000000 --- a/static/bv/bracket.css +++ /dev/null @@ -1,1286 +0,0 @@ -/* =================== - 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 deleted file mode 100644 index 3c0e351..0000000 --- a/static/bv/bracket.js +++ /dev/null @@ -1,4075 +0,0 @@ -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.} rankThresholds - Score thresholds for each rank - */ - - /** - * @param {Partial} 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} 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} hintModeClues - Clues that have been peeked - * @property {Set} peekedClues - Tracking for peek penalties - * @property {Set} 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: ` -
    -
  • Click ? to access the tutorial
  • -
  • Click ! to access news and settings
  • -
  • Click the date header to access the date picker and browse the archive
  • -
-

Thank you for playing Bracket Village!

- `, - 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} 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.map(p => p.puzzleDate).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) + - `${escapedSolution}` + - 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) + - `${escapedSolution}` + - 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) + - `${escapedSolution}` + - 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 = '
Loading calendar...
'; - - try { - // Fetch available puzzles - const availableDates = await this.fetchAvailablePuzzleDates(); - if (!availableDates || availableDates.length === 0) { - container.innerHTML = '
No puzzles available
'; - 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 = '
Failed to load calendar
'; - } - } - - /** - * 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 = ` -
- -

${monthName}

- -
- -
-
Su
-
Mo
-
Tu
-
We
-
Th
-
Fr
-
Sa
-
- -
- `; - - // Add empty cells for days before the month starts - for (let i = 0; i < startDay; i++) { - calendarHTML += `
`; - } - - // 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 += ` -
- ${day} -
- `; - } - - calendarHTML += ` -
- - `; - - // 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} 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 ``; - }); - } - - /** - * 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 - : `${this.PUZZLE_DATA.completionText}\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 + `${clueText}` + after; - } - }); - - return highlightedText; - } - - /*************** - HTML generation - ****************/ - - generateLoadingHTML() { - return ` -
- Loading today's puzzle... -
- `; - } - - /** - * Generates HTML for the input container based on current mode - * @returns {string} HTML markup - */ - generateInputContainerHTML() { - if (this.useCustomKeyboard) { - if (this.inputMode === 'submit') { - return ` -
-
-
-
- type any answer... -
- -
-
- ${this.generateKeyboardButtonsHTML()} -
-
- `; - } else { - // Classic mode (auto-snap) - return ` -
-
-
- start typing answers... -
-
- ${this.generateKeyboardButtonsHTML()} -
-
- `; - } - } else { - // Desktop - if (this.inputMode === 'submit') { - return ` -
-
- - -
-
-
- `; - } else { - // Classic mode (auto-snap) - return ` -
- -
-
- `; - } - } - } - -// 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 += `
`; - - 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 += '
'; - }); - - html += ''; - return html; -} - -generateSpecialKeyHTML(keyItem) { - const fontSize = keyItem.small ? '0.875rem' : '1.125rem'; - return ` - - `; -} - - generateKeyboardKeyHTML(key) { - return ` - - `; - } - - /** - * Generates initial HTML structure for the game - * @returns {string} HTML markup - */ - generateInitialHTML() { - const displayDate = this.currentPuzzleDate || this.getNYCDate(); - - return ` -
- ${this.generateHeaderHTML(displayDate)} -
- ${this.generatePuzzleDisplayHTML()} - ${this.generateInputContainerHTML()} - ${this.generateStatsContainerHTML()} - ${this.generateSolvedExpressionsHTML()} -
-
- `; - } - - generateHeaderHTML(displayDate) { - return ` -
- -

[Bracket Village]

- - -
- `; - } - - generatePuzzleDisplayHTML() { - return `
`; - } - - generateStatsContainerHTML() { - return ` - - `; - } - - _generateSolvedExpressionItemHTML(expression, solution) { - return ` -
- [${expression}] = - ${solution} -
- `; - } - - 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 ` -
-

-
- ${expressionsList} -
-
- `; - } - - generateCompletionMessage() { - return ` -
- ** Puzzle Solved! **
- - ${this.PUZZLE_DATA.completionURL} - -
- `; - } - - 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 = '
now I am become Death, the destroyer of worlds'; - } else if (stats.rank === 'Kingmaker') { - nextRankMessage = '
ah finally the very top'; - } else if (stats.rank === 'Mayor') { - nextRankMessage = '
nobody\'s my boss, right?'; - } else if (stats.rank === 'Power Broker') { - nextRankMessage = '
this has got to be it...'; - } else if (stats.nextRankName) { - nextRankMessage = `
Almost: ${stats.nextRankName} (${Math.ceil(stats.pointsToNextRank)} points needed)`; - } - - return ` -
- You are ${(stats.rank === 'Chief of Police' || stats.rank === 'Mayor') ? 'the' : 'a'} Bracket Village
- ${stats.rankEmoji} ${stats.rank} ${stats.rankEmoji} - ${nextRankMessage} - -
- `; - } - - - /** - * 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 = ` -
-
Score: ${stats.finalScore.toFixed(1)}
-
${progressBar}
- ${this.inputMode === 'classic' ? '
\u{2620}\u{FE0F} hard mode!
' : ''} -
- `; - - // For classic mode (keystroke-based) - if (this.inputMode === 'classic') { - return ` - ${scoreBarHTML} -
-
Total keystrokes: ${this.state.totalKeystrokes}
-
Minimum keystrokes needed: ${this.state.minimumKeystrokes}
-
Excess keystrokes: ${this.state.totalKeystrokes - this.state.minimumKeystrokes}
-
\u{1F440} Clues peeked: ${this.state.peekedClues.size}
-
\u{1F6DF} Answers revealed: ${this.state.megaPeekedClues.size}
-
Score breakdown -
Base score (efficiency): ${stats.baseScore.toFixed(1)} -
Peek penalty: -${stats.peekPenalty.toFixed(1)} - ${stats.megaPeekPenalty > 0 ? `
Reveal penalty: -${stats.megaPeekPenalty.toFixed(1)}` : ''} -
Final score: ${stats.finalScore.toFixed(1)} -
-
- `; - } - // For submit mode (wrong-guess-based) - else { - return ` - ${scoreBarHTML} -
-
\u{274C} Wrong guesses: ${this.state.wrongGuesses || 0}
-
\u{1F440} Clues peeked: ${this.state.peekedClues.size}
-
\u{1F6DF} Answers revealed: ${this.state.megaPeekedClues.size}
-
Score breakdown -
Base score: ${stats.baseScore.toFixed(1)} - ${stats.wrongGuessPenalty > 0 ? `
Wrong guess penalty: -${stats.wrongGuessPenalty.toFixed(1)}` : ''} -
Peek penalty: -${stats.peekPenalty.toFixed(1)} - ${stats.megaPeekPenalty > 0 ? `
Reveal penalty: -${stats.megaPeekPenalty.toFixed(1)}` : ''} -
Final score: ${stats.finalScore.toFixed(1)} -
-
Total keystrokes: ${this.state.totalKeystrokes}
-
Minimum keystrokes needed: ${this.state.minimumKeystrokes}
-
Excess keystrokes: ${this.state.totalKeystrokes - this.state.minimumKeystrokes}
-
- `; - } - } - - /************************** - 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 = ` - - `; - - // Build the info content with the toggle at the top - - // old news - //
* clicking a clue lets you peek at the answer's first letter
- //
* clicking ? takes you to an interactive tutorial
- - const infoContent = ` - ${toggleStyle} -
-
-
- hard mode - -
-
- no submit button & every keystroke counts! - - (can't change once a puzzle started) - -
-
-
- -
* there is now a date picker - just click the date in the header and browse the archive
- -
* check out the original Bracket City written by (hopefully) friend of the village james somers
- -
* bracket village site made by ingloriously copying the code from the original site without permission and fixing the bugs
- `; - - 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 = '
' + 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 = ` - -
- `; - // 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 = ` -
-
-
${title}
-
${message}
- -
-
- `; - - // 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 deleted file mode 100644 index cecdf44..0000000 --- a/static/bv/index.html +++ /dev/null @@ -1,30 +0,0 @@ - - - - - - - - - [Bracket Village] - - - - - - - - - - - - - -
- - - - - diff --git a/static/bv/puzzleencoder.js b/static/bv/puzzleencoder.js deleted file mode 100644 index fcba62c..0000000 --- a/static/bv/puzzleencoder.js +++ /dev/null @@ -1,83 +0,0 @@ -// 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 deleted file mode 100644 index ac49d70..0000000 --- a/static/bv/tutorial/index.html +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - - Bracket Village - - - - - - -
- - - - - - - - diff --git a/static/bv/tutorial/tutorial-init.js b/static/bv/tutorial/tutorial-init.js deleted file mode 100644 index ba0cc73..0000000 --- a/static/bv/tutorial/tutorial-init.js +++ /dev/null @@ -1,22 +0,0 @@ -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 deleted file mode 100644 index 4f2aff0..0000000 --- a/static/bv/tutorial/tutorial.css +++ /dev/null @@ -1,945 +0,0 @@ -/* =================== - 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 deleted file mode 100644 index 33f9258..0000000 --- a/static/bv/tutorial/tutorial.js +++ /dev/null @@ -1,1011 +0,0 @@ -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 any clue just by submitting an answer
No need to click, just type the answer to any highlighted clue and hit enter!
Keep guessing until you get one!", - highlight: "clue" - }, - { - message: "Nice! pool is correct!
Clues are often nested within other clues
You'll need to solve opposite of clean to reveal its parent clue about dishes", - condition: expressions => expressions.size === 1 && expressions.has("game played with a cue ball"), - highlight: "none" - }, - { - message: "Nice! dirty is correct!
Clues are often nested within other clues
Now solve game played with a cue ball to reveal the parent clue about exercise", - condition: expressions => expressions.size === 1 && expressions.has("opposite of clean"), - highlight: "none" - }, - { - message: "Excellent! pool is correct!
Now both parent clues are revealed, so they are both highlighted and solvable
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! dirty is correct!
Now both parent clues are revealed, so they are both highlighted and solvable
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 sink instead
You still need to solve game played with a cue ball
Looking at the parent clue 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 swim instead
You still need to solve opposite of clean
Looking at the parent clue 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 swim is right. Only one more clue to go...
Try clicking it to reveal the first letter, that's called a \"peek\"
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 sink is right. Only one more clue to go...
Try clicking it to reveal the first letter, that's called a \"peek\"
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 dirty is right. Only one more clue to go...
Try clicking it to reveal the first letter, that's called a \"peek\"
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 pool is right. Only one more clue to go...
Try clicking it to reveal the first letter, that's called a \"peek\"
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: "Just to recap:
* you can solve any highlighted clue \u{2013} just type your guess and hit enter
* click once on a clue to peek at the first letter, twice to reveal 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 ` -
- ${this.generateHeaderHTML()} -
- ${this.generateTutorialGuidanceHTML()} - ${this.generatePuzzleDisplayHTML()} - - ${this.generateInputContainerHTML()} -
-
- `; - } - - /** - * Generates the header HTML - */ - generateHeaderHTML() { - return ` -
-

[Bracket Village]

- - -
- `; - } - - /** - * Generates the puzzle display HTML with fixed height - */ - generatePuzzleDisplayHTML() { - return `
`; - } - - /** - * Generates the tutorial guidance container HTML - */ - generateTutorialGuidanceHTML() { - return ` -
-
-
- `; - } - - /** - * Generates the input container HTML based on device type - */ - generateInputContainerHTML() { - if (this.isMobile) { - return ` -
-
-
-
- type any answer... -
- -
-
- ${this.generateKeyboardButtonsHTML()} -
-
- `; - } - - return ` -
-
- - -
-
-
- `; - } - - /** - * 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 += `
`; - - 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 += '
'; - }); - - return html; - } - - /** - * Generates a special keyboard key HTML - */ - generateSpecialKeyHTML(keyItem) { - const fontSize = keyItem.small ? '0.875rem' : '1.125rem'; - - return ` - - `; - } - - /** - * Generates a regular keyboard key HTML - */ - generateKeyboardKeyHTML(key) { - return ` - - `; - } - - /** - * 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 = ` -
Welcome to the Bracket Village Tutorial
-
* In Bracket Village, you solve [active clues] by typing the answers
-
* You can tap any [active clue] to peek at its first letter, or tap again to reveal the full answer
-
* This tutorial will guide you through the basics
-
* Tap anywhere to continue
- `; - - 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 + `${clueText}` + 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 = ` -
-
🎉 Tutorial Complete! 🎉
-
Ready to play the full game?
- -
- `; - - // 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) + - `${escapedSolution}` + - 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) + - `${escapedSolution}` + - 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 }; - } -- cgit v1.2.3