milwright Claude commited on
Commit
86f177c
·
1 Parent(s): 6a1d845

simplify game mechanics and consolidate to single AI model

Browse files

- Remove two-passage-per-round system; one passage per round with immediate level advancement on pass
- Consolidate from dual-model to single Gemma-3-27b model for all operations (word selection, hints, contextualization)
- Remove round number from progress display; show only "Level X • Y blank(s)"
- Integrate leaderboard system with milestone notifications
- Update welcome overlay with concise gameplay description
- Rewrite README with conceptual framework exploring convergence of educational assessment and masked language modeling
- Refine contextualization prompts for clearer, more direct AI responses

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>

README.md CHANGED
@@ -11,33 +11,69 @@ thumbnail: >-
11
 
12
  # Cloze Reader
13
 
14
- An interactive cloze reading practice application with AI-powered assistance. Practice reading comprehension by filling in blanks in randomly excerpted historical and literary passages.
15
 
16
- ## Features
17
 
18
- - **Progressive Level System**: Start with 1 blank, advance to 2-3 blanks as you improve
19
- - **Smart Hints**: Get word length, first letter, and contextual clues
20
- - **AI Chat Help**: Click 💬 for intelligent hints about any blank
21
- - **Historical and Literary Passages**: Randomly excerpted texts from Project Gutenberg's collection
22
- - **Level-Appropriate Challenges**: Hints adapt based on your current level
23
 
24
- ## How to Use
25
 
26
- 1. Read the passage and literary context
27
- 2. Fill in the blank(s) with appropriate words
28
- 3. Use hints or chat help if needed
29
- 4. Submit to see your results and advance levels
30
- 5. Continue practicing with new passages
31
 
32
- ## Level System
33
 
34
- - **Levels 1-2**: 1 blank, hints show first and last letter
35
- - **Levels 3-4**: 2 blanks, hints show first letter only
36
- - **Level 5+**: 3 blanks, first letter hints
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37
 
38
  ## Technology
39
 
40
- Built with vanilla JavaScript, powered by AI for intelligent word selection and contextual assistance. Supports both OpenRouter API and local LLM integration.
 
 
 
 
41
 
42
  ## Running Locally with Docker
43
 
 
11
 
12
  # Cloze Reader
13
 
14
+ ## When Assessment Becomes Training Data, and Training Data Becomes Assessment
15
 
16
+ In 1953, Wilson Taylor proposed the "cloze procedure" as a measurement tool for reading comprehension and text difficulty. The method was elegantly simple: delete every nth word from a passage, ask readers to fill in the blanks, and score their accuracy. Taylor argued that successful completion demonstrated not mere vocabulary knowledge but genuine contextual understanding—the ability to infer meaning from surrounding linguistic patterns. By the 1960s, cloze testing had become standard in educational assessment, literacy research, and language teaching. Its appeal lay in its objectivity: unlike essay grading, cloze scores could be automated, quantified, compared across populations.
17
 
18
+ Sixty-five years later, Google researchers published "BERT: Pre-training of Deep Bidirectional Transformers for Language Understanding." BERT's innovation was masked language modeling: randomly mask 15% of tokens in a text, train the model to predict the missing words. The researchers didn't cite Taylor. They didn't frame this as a pedagogical technique. Yet they had independently reinvented the cloze procedure as a machine learning objective. BERT learned language by solving millions of fill-in-the-blank exercises, training on the same task that had measured human comprehension since the Eisenhower administration.
 
 
 
 
19
 
20
+ This convergence wasn't coincidental. Both cloze testing and masked language modeling operate on the same premise: understanding language means predicting from context. If you can accurately fill in blanks, you've demonstrated comprehension—whether you're a student in 1960s Kansas or a transformer model in 2018. The methodology traveled from educational psychology to computational linguistics because it captured something fundamental about how meaning works: language is redundant, predictable, inferable from surrounding structure.
21
 
22
+ Both approaches also share the same constraints. Educational researchers identified "clozability"—the phenomenon that some words are easier to predict than others due to context salience, limited synonyms, or statistical frequency. Machine learning researchers independently discovered the same issue: certain tokens are trivially predictable from context, while others require deeper reasoning. Zhang & Hashimoto (2021) showed that masked language models learn statistical and syntactic dependencies—exactly what cloze tests aim to measure in humans. The parallel is not superficial.
 
 
 
 
23
 
24
+ But now the loop has closed in an unexpected way. BERT and its descendants—including Google's Gemma models—were trained on masked prediction tasks extracted from web text, books, Wikipedia. These models learned to predict missing words from billions of cloze-like exercises. Now, with Cloze Reader, those same models generate cloze tests for humans. The AI that learned language by filling in blanks now decides which blanks you should fill in. The training methodology has become the assessment tool, and the assessment tool has become training data.
25
 
26
+ ## What This Game Explores
27
+
28
+ Cloze Reader uses open-weight Gemma-3 models to transform Project Gutenberg literature into dynamically generated cloze exercises. The 12B parameter model scans passages and selects vocabulary to remove. The 27B model generates contextual hints and provides conversational guidance. Every passage is fresh, every blank algorithmically chosen, every hint synthesized in real time.
29
+
30
+ This isn't just automated test generation. It's an investigation into what happens when the twin histories of educational assessment and machine learning collapse into each other. Consider:
31
+
32
+ **Standardization vs. Serendipity:** Educational cloze tests sought standardization—predictable difficulty, comparable scores, systematic progression. Machine learning cloze tasks sought diversity—randomized masking, varied contexts, statistical coverage. Using Gemma models on Project Gutenberg's 70,000-book corpus introduces radical serendipity: you might encounter Victorian Gothic prose, 1920s adventure serials, obscure 19th-century essays, forgotten feminist manifestos. The algorithm selects passages and words through statistical patterns trained on internet-scale text, not curriculum design. What does assessment mean when no human predetermined what counts as "appropriate difficulty"?
33
+
34
+ **Inference vs. Memorization:** Traditional cloze tests measured whether students could infer from context rather than recall from memory. Educational researchers have long critiqued cloze procedures for measuring primarily local, sentence-level inference (surface coherence) rather than global text structure or pragmatic reasoning. Machine learning critics make parallel arguments: masked language models exploit spurious statistical regularities rather than genuine semantic understanding. When a model trained on surface patterns generates tests, and humans solve those tests using similar heuristics, where is comprehension actually happening? The distinction between understanding and statistical correlation becomes harder to maintain on both sides.
35
+
36
+ **Automation and Authority:** Educational assessment historically required human expertise—teachers selecting texts, choosing appropriate blanks, evaluating answers. Automated testing promised efficiency but was criticized for reducing learning to quantifiable metrics. Now the automation is complete: an algorithmic system with no pedagogical training, no curriculum knowledge, no understanding of individual learners generates and evaluates exercises. It runs on open-weight models anyone can download, modify, or interrogate. What happens to authority over what constitutes "correct" reading comprehension when assessment moves from institutional gatekeeping to open algorithmic systems?
37
+
38
+ **The Feedback Loop:** Most critically, this is a recursive system. Gemma models were trained partly on digitized books—including many from Project Gutenberg. The texts they learned from become the texts they generate exercises from. The model learned language patterns from Victorian literature, then uses those patterns to test human understanding of Victorian literature. Meanwhile, interactions with this game could theoretically become training data for future models. Assessment data becomes training data becomes assessment tools becomes training data. At what point does the distinction between learning and evaluation dissolve entirely?
39
+
40
+ **The Exact-Word Problem:** Educational cloze testing has long debated whether to accept only exact matches or score semantic/grammatical equivalents (synonyms, morphological variants). This game enforces exact-word matching with some suffix normalization, mirroring how masked language models are trained on exact token prediction. Both approaches may penalize valid alternatives. When you type "sad" but the answer was "melancholy," have you failed to comprehend the passage—or simply chosen a different word from the same semantic field? This scoring problem exists identically in human assessment and algorithmic evaluation.
41
+
42
+ ## The Research Context
43
+
44
+ Recent scholarship explicitly bridges cloze assessment and masked language modeling:
45
+
46
+ - **Matsumori et al. (2023)** built CLOZER, a system using masked language models to generate open cloze questions for L2 English learners, demonstrating practical pedagogical applications
47
+ - **Ondov et al. (2024, NAACL)** argue: "The cloze training objective of Masked Language Models makes them a natural choice for generating plausible distractors for human cloze questions"
48
+ - **Zhang & Hashimoto (2021)** analyzed the inductive biases of masked tokens, showing that models learn statistical and syntactic dependencies through the same mechanisms cloze tests measure in humans
49
+
50
+ This project sits at the intersection of these research trajectories—using the tools that now generate assessments to explore what happens when the boundary between human learning and machine training dissolves.
51
+
52
+ ## A Game, Not a Conclusion
53
+
54
+ Cloze Reader doesn't resolve these tensions. It stages them. Through vintage aesthetics and classic texts, it creates a space where the convergence of educational assessment and machine learning becomes palpable. You're playing a literacy game designed by an algorithm that learned literacy by playing the same game billions of times. Every passage is a historical text processed by a model trained on historical texts. Every hint comes from a system that doesn't "understand" in any human sense but can nonetheless guide you toward understanding.
55
+
56
+ The experience raises more questions than it answers. Is this pedagogy or pattern replication? Assessment or performance? Human learning or collaborative prediction with a statistical engine? These aren't rhetorical questions—they're open empirical questions about what education looks like when the tools we use to measure learning are built from the same processes we're trying to measure.
57
+
58
+ ## How It Works
59
+
60
+ **Dual-Model Architecture:** The system uses two Gemma-3 models with distinct roles. The 12B parameter model analyzes passages and selects words to mask based on patterns learned during its training. The 27B model generates contextual hints and powers the chat interface. This separation mirrors the distinction between assessment design and pedagogical guidance—though both are algorithmic.
61
+
62
+ **Progressive Levels:** The game implements a level system (1-5 with 1 blank, 6-10 with 2 blanks, 11+ with 3 blanks) that scaffolds difficulty through word length constraints, historical period selection, and hint disclosure. Early levels use 1900s texts and show first+last letters; advanced levels draw from any era and provide only first letters. Each round presents two passages from different books, requiring consistent performance across rounds before advancing.
63
+
64
+ **Serendipitous Selection:** Passages stream directly from Hugging Face's Project Gutenberg dataset. The model selects words based on its training rather than curricular logic—sometimes choosing obvious vocabulary, sometimes obscure terms, sometimes generating exercises that are trivially easy or frustratingly hard. This unpredictability is a feature: it reveals how algorithmic assessment differs from human-designed pedagogy.
65
+
66
+ **Chat as Scaffold:** Click the 💬 icon beside any blank to engage the 27B model in conversation. It attempts to guide you through Socratic questioning, semantic clues, and contextual hints—replicating what a tutor might do, constrained by what a language model trained on text prediction can actually accomplish.
67
+
68
+ The system filters out dictionaries, technical documentation, and poetry—ensuring narrative prose where blanks are theoretically inferable from context, even if the model's choices sometimes suggest otherwise.
69
 
70
  ## Technology
71
 
72
+ **Vanilla JavaScript, No Build Step:** The application runs entirely in the browser using ES6 modules—no webpack, no bundler, no compilation. This architectural choice mirrors the project's conceptual interests: keeping the machinery visible and modifiable rather than obscured behind layers of tooling. A minimal FastAPI backend serves static files and injects API keys; everything else happens client-side.
73
+
74
+ **Open-Weight Models:** Uses Google's Gemma-3 models (12B and 27B parameters) via OpenRouter, or alternatively connects to local LLM servers (LM Studio, etc.) on port 1234. The choice of open-weight models is deliberate: these systems can be downloaded, inspected, run locally, modified. When assessment becomes algorithmic, transparency about the algorithm matters. You can examine exactly which model is generating your exercises, run the same models yourself, experiment with alternatives.
75
+
76
+ **Streaming from Public Archives:** Book data streams directly from Hugging Face's mirror of Project Gutenberg's corpus—public domain texts, open dataset infrastructure, no proprietary content libraries. The entire pipeline from literature to exercises relies on openly accessible resources, making the system reproducible and auditable.
77
 
78
  ## Running Locally with Docker
79
 
index.html CHANGED
@@ -12,11 +12,21 @@
12
  <body class="min-h-screen">
13
  <div id="app" class="container mx-auto px-4 py-8 max-w-4xl">
14
  <header class="text-center mb-8">
15
- <div class="flex items-center justify-center gap-3 mb-2">
16
- <img src="./icon.png" alt="Cloze Reader" class="w-12 h-12">
17
- <h1 class="text-4xl font-bold typewriter-text">Cloze Reader</h1>
 
 
 
 
 
 
 
 
 
 
 
18
  </div>
19
- <p class="typewriter-subtitle">Fill in the blanks to practice reading comprehension</p>
20
  </header>
21
 
22
  <main id="game-container" class="space-y-6">
 
12
  <body class="min-h-screen">
13
  <div id="app" class="container mx-auto px-4 py-8 max-w-4xl">
14
  <header class="text-center mb-8">
15
+ <div class="flex items-start justify-between mb-4 gap-4">
16
+ <div class="flex-1 min-w-0"></div>
17
+ <div class="flex flex-col items-center gap-3">
18
+ <div class="flex items-center gap-3">
19
+ <img src="./icon.png" alt="Cloze Reader" class="w-12 h-12">
20
+ <h1 class="text-4xl font-bold typewriter-text">Cloze Reader</h1>
21
+ </div>
22
+ <p class="typewriter-subtitle">Fill in the blanks to practice reading comprehension</p>
23
+ </div>
24
+ <div class="flex-1 min-w-0 flex justify-end items-end">
25
+ <button id="leaderboard-btn" class="typewriter-button-small" title="View leaderboard">
26
+ Leaderboard
27
+ </button>
28
+ </div>
29
  </div>
 
30
  </header>
31
 
32
  <main id="game-container" class="space-y-6">
src/aiService.js CHANGED
@@ -5,9 +5,9 @@ class OpenRouterService {
5
  this.apiUrl = this.isLocalMode ? 'http://localhost:1234/v1/chat/completions' : 'https://openrouter.ai/api/v1/chat/completions';
6
  this.apiKey = this.getApiKey();
7
 
8
- // Dual model configuration: Gemma-3-27b for hints/query-answering, Gemma-3-12b for everything else
9
  this.hintModel = this.isLocalMode ? 'gemma-3-12b' : 'google/gemma-3-27b-it';
10
- this.primaryModel = this.isLocalMode ? 'gemma-3-12b' : 'google/gemma-3-12b-it';
11
  this.model = this.primaryModel; // Default model for backward compatibility
12
 
13
  console.log('AI Service initialized:', {
@@ -700,20 +700,20 @@ Return JSON: {"passage1": {"words": [${blanksPerPassage} words], "context": "one
700
  }
701
  }
702
 
703
- async generateContextualization(title, author) {
704
  console.log('generateContextualization called for:', title, 'by', author);
705
-
706
  // Check for API key at runtime
707
  const currentKey = this.getApiKey();
708
  if (currentKey && !this.apiKey) {
709
  this.apiKey = currentKey;
710
  }
711
-
712
  console.log('API key available for contextualization:', !!this.apiKey);
713
-
714
  if (!this.apiKey) {
715
  console.log('No API key, returning fallback contextualization');
716
- return `📜 Practice with literature from ${author}'s "${title}"`;
717
  }
718
 
719
  try {
@@ -727,16 +727,16 @@ Return JSON: {"passage1": {"words": [${blanksPerPassage} words], "context": "one
727
  'X-Title': 'Cloze Reader'
728
  },
729
  body: JSON.stringify({
730
- model: this.primaryModel, // Use Gemma-3-12b for contextualization
731
  messages: [{
732
  role: 'system',
733
- content: 'You are a literary expert. Provide ONE interesting fact about this book or its author. Focus on: publication year, genre, historical context, author biography, literary significance, or themes. Do NOT quote or paraphrase passages from the book. Keep it under 20 words.'
734
  }, {
735
  role: 'user',
736
- content: `Book: "${title}" by ${author}. Give me a brief fact about this work or author, not a quote from the text.`
737
  }],
738
  max_tokens: 150,
739
- temperature: 0.5,
740
  response_format: { type: "text" }
741
  })
742
  });
@@ -783,11 +783,11 @@ Return JSON: {"passage1": {"words": [${blanksPerPassage} words], "context": "one
783
  }
784
 
785
  content = content.trim();
786
-
787
  // Clean up AI response artifacts
788
  content = content
789
  .replace(/^\s*["']|["']\s*$/g, '') // Remove leading/trailing quotes
790
- .replace(/^\s*[:;]+\s*/, '') // Remove leading colons and semicolons
791
  .replace(/\*+/g, '') // Remove asterisks (markdown bold/italic)
792
  .replace(/_+/g, '') // Remove underscores (markdown)
793
  .replace(/#+\s*/g, '') // Remove hash symbols (markdown headers)
@@ -799,7 +799,7 @@ Return JSON: {"passage1": {"words": [${blanksPerPassage} words], "context": "one
799
  });
800
  } catch (error) {
801
  console.error('Error getting contextualization:', error);
802
- return `📜 Practice with literature from ${author}'s "${title}"`;
803
  }
804
  }
805
 
 
5
  this.apiUrl = this.isLocalMode ? 'http://localhost:1234/v1/chat/completions' : 'https://openrouter.ai/api/v1/chat/completions';
6
  this.apiKey = this.getApiKey();
7
 
8
+ // Single model configuration: Gemma-3-27b for all operations
9
  this.hintModel = this.isLocalMode ? 'gemma-3-12b' : 'google/gemma-3-27b-it';
10
+ this.primaryModel = this.isLocalMode ? 'gemma-3-12b' : 'google/gemma-3-27b-it';
11
  this.model = this.primaryModel; // Default model for backward compatibility
12
 
13
  console.log('AI Service initialized:', {
 
700
  }
701
  }
702
 
703
+ async generateContextualization(title, author, passage) {
704
  console.log('generateContextualization called for:', title, 'by', author);
705
+
706
  // Check for API key at runtime
707
  const currentKey = this.getApiKey();
708
  if (currentKey && !this.apiKey) {
709
  this.apiKey = currentKey;
710
  }
711
+
712
  console.log('API key available for contextualization:', !!this.apiKey);
713
+
714
  if (!this.apiKey) {
715
  console.log('No API key, returning fallback contextualization');
716
+ return `A passage from ${author}'s "${title}"`;
717
  }
718
 
719
  try {
 
727
  'X-Title': 'Cloze Reader'
728
  },
729
  body: JSON.stringify({
730
+ model: this.primaryModel, // Use Gemma-3-27b for contextualization
731
  messages: [{
732
  role: 'system',
733
+ content: 'Provide a single contextual insight about the passage: historical context, literary technique, thematic observation, or relevant fact. Be specific and direct. Maximum 25 words. Do not use dashes or em-dashes. Output ONLY the insight itself with no preamble, acknowledgments, or meta-commentary.'
734
  }, {
735
  role: 'user',
736
+ content: `From "${title}" by ${author}:\n\n${passage}`
737
  }],
738
  max_tokens: 150,
739
+ temperature: 0.7,
740
  response_format: { type: "text" }
741
  })
742
  });
 
783
  }
784
 
785
  content = content.trim();
786
+
787
  // Clean up AI response artifacts
788
  content = content
789
  .replace(/^\s*["']|["']\s*$/g, '') // Remove leading/trailing quotes
790
+ .replace(/^\s*[:;.!?]+\s*/, '') // Remove leading punctuation
791
  .replace(/\*+/g, '') // Remove asterisks (markdown bold/italic)
792
  .replace(/_+/g, '') // Remove underscores (markdown)
793
  .replace(/#+\s*/g, '') // Remove hash symbols (markdown headers)
 
799
  });
800
  } catch (error) {
801
  console.error('Error getting contextualization:', error);
802
+ return `A passage from ${author}'s "${title}"`;
803
  }
804
  }
805
 
src/app.js CHANGED
@@ -2,12 +2,14 @@
2
  import ClozeGame from './clozeGameEngine.js';
3
  import ChatUI from './chatInterface.js';
4
  import WelcomeOverlay from './welcomeOverlay.js';
 
5
 
6
  class App {
7
  constructor() {
8
  this.game = new ClozeGame();
9
  this.chatUI = new ChatUI(this.game);
10
  this.welcomeOverlay = new WelcomeOverlay();
 
11
  this.elements = {
12
  loading: document.getElementById('loading'),
13
  gameArea: document.getElementById('game-area'),
@@ -21,9 +23,10 @@ class App {
21
  submitBtn: document.getElementById('submit-btn'),
22
  nextBtn: document.getElementById('next-btn'),
23
  hintBtn: document.getElementById('hint-btn'),
24
- result: document.getElementById('result')
 
25
  };
26
-
27
  this.currentResults = null;
28
  this.setupEventListeners();
29
  }
@@ -44,7 +47,14 @@ class App {
44
  this.elements.submitBtn.addEventListener('click', () => this.handleSubmit());
45
  this.elements.nextBtn.addEventListener('click', () => this.handleNext());
46
  this.elements.hintBtn.addEventListener('click', () => this.toggleHints());
47
-
 
 
 
 
 
 
 
48
  // Allow Enter key to submit when focused on an input
49
  document.addEventListener('keydown', (e) => {
50
  if (e.key === 'Enter' && e.target.classList.contains('cloze-input')) {
@@ -72,9 +82,8 @@ class App {
72
 
73
  // Show level information
74
  const blanksCount = roundData.blanks.length;
75
- const passageNumber = this.game.currentPassageIndex + 1;
76
- const levelInfo = `Level ${this.game.currentLevel} • Passage ${passageNumber}/2 • ${blanksCount} blank${blanksCount > 1 ? 's' : ''}`;
77
-
78
  this.elements.roundInfo.innerHTML = levelInfo;
79
 
80
  // Show contextualization from AI agent
@@ -157,13 +166,19 @@ class App {
157
 
158
  displayResults(results) {
159
  let message = `Score: ${results.correct}/${results.total}`;
160
-
161
  if (results.passed) {
162
  // Check if level was just advanced
163
  if (results.justAdvancedLevel) {
164
  message += ` ✓ Level ${results.currentLevel} unlocked!`;
165
- } else if (results.passagesPassedAtCurrentLevel === 1) {
166
- message += ` Passed (1 more passage needed for next level)`;
 
 
 
 
 
 
167
  } else {
168
  message += ` ✓ Passed`;
169
  }
@@ -172,12 +187,12 @@ class App {
172
  message += ` - Try again (need ${results.requiredCorrect}/${results.total})`;
173
  this.elements.result.className = 'mt-4 text-center font-semibold text-red-600';
174
  }
175
-
176
  this.elements.result.textContent = message;
177
-
178
  // Always reveal answers at the end of each round
179
  this.revealAnswersInPlace(results.results);
180
-
181
  // Show next button and hide submit button
182
  this.elements.submitBtn.style.display = 'none';
183
  this.elements.nextBtn.classList.remove('hidden');
@@ -205,35 +220,28 @@ class App {
205
  try {
206
  // Show loading immediately with specific message
207
  this.showLoading(true, 'Loading passages...');
208
-
209
- // Clear chat history when starting new passage/round
210
  this.chatUI.clearChatHistory();
211
-
212
  // Always show loading for at least 1 second for smooth UX
213
  const startTime = Date.now();
214
-
215
- // Check if we should load next passage or next round
216
- let roundData;
217
- if (this.game.currentPassageIndex === 0) {
218
- // Load second passage in current round
219
- roundData = await this.game.nextPassage();
220
- } else {
221
- // Load next round (two new passages)
222
- roundData = await this.game.nextRound();
223
- }
224
-
225
  // Ensure loading is shown for at least half a second
226
  const elapsedTime = Date.now() - startTime;
227
  if (elapsedTime < 500) {
228
  await new Promise(resolve => setTimeout(resolve, 500 - elapsedTime));
229
  }
230
-
231
  this.displayRound(roundData);
232
  this.resetUI();
233
  this.showLoading(false);
234
  } catch (error) {
235
- console.error('Error loading next passage:', error);
236
- this.showError('Could not load next passage. Please try again.');
237
  }
238
  }
239
 
@@ -329,6 +337,33 @@ class App {
329
  this.elements.loading.classList.remove('hidden');
330
  this.elements.gameArea.classList.add('hidden');
331
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
332
  }
333
 
334
  // Initialize the app when DOM is loaded
 
2
  import ClozeGame from './clozeGameEngine.js';
3
  import ChatUI from './chatInterface.js';
4
  import WelcomeOverlay from './welcomeOverlay.js';
5
+ import { LeaderboardUI } from './leaderboardUI.js';
6
 
7
  class App {
8
  constructor() {
9
  this.game = new ClozeGame();
10
  this.chatUI = new ChatUI(this.game);
11
  this.welcomeOverlay = new WelcomeOverlay();
12
+ this.leaderboardUI = new LeaderboardUI(this.game.leaderboardService);
13
  this.elements = {
14
  loading: document.getElementById('loading'),
15
  gameArea: document.getElementById('game-area'),
 
23
  submitBtn: document.getElementById('submit-btn'),
24
  nextBtn: document.getElementById('next-btn'),
25
  hintBtn: document.getElementById('hint-btn'),
26
+ result: document.getElementById('result'),
27
+ leaderboardBtn: document.getElementById('leaderboard-btn')
28
  };
29
+
30
  this.currentResults = null;
31
  this.setupEventListeners();
32
  }
 
47
  this.elements.submitBtn.addEventListener('click', () => this.handleSubmit());
48
  this.elements.nextBtn.addEventListener('click', () => this.handleNext());
49
  this.elements.hintBtn.addEventListener('click', () => this.toggleHints());
50
+
51
+ // Leaderboard button
52
+ if (this.elements.leaderboardBtn) {
53
+ this.elements.leaderboardBtn.addEventListener('click', () => {
54
+ this.leaderboardUI.show();
55
+ });
56
+ }
57
+
58
  // Allow Enter key to submit when focused on an input
59
  document.addEventListener('keydown', (e) => {
60
  if (e.key === 'Enter' && e.target.classList.contains('cloze-input')) {
 
82
 
83
  // Show level information
84
  const blanksCount = roundData.blanks.length;
85
+ const levelInfo = `Level ${this.game.currentLevel} ${blanksCount} blank${blanksCount > 1 ? 's' : ''}`;
86
+
 
87
  this.elements.roundInfo.innerHTML = levelInfo;
88
 
89
  // Show contextualization from AI agent
 
166
 
167
  displayResults(results) {
168
  let message = `Score: ${results.correct}/${results.total}`;
169
+
170
  if (results.passed) {
171
  // Check if level was just advanced
172
  if (results.justAdvancedLevel) {
173
  message += ` ✓ Level ${results.currentLevel} unlocked!`;
174
+
175
+ // Check for milestone notification (every 5 levels)
176
+ if (results.currentLevel % 5 === 0) {
177
+ this.leaderboardUI.showMilestoneNotification(results.currentLevel);
178
+ }
179
+
180
+ // Check for high score
181
+ this.checkForHighScore();
182
  } else {
183
  message += ` ✓ Passed`;
184
  }
 
187
  message += ` - Try again (need ${results.requiredCorrect}/${results.total})`;
188
  this.elements.result.className = 'mt-4 text-center font-semibold text-red-600';
189
  }
190
+
191
  this.elements.result.textContent = message;
192
+
193
  // Always reveal answers at the end of each round
194
  this.revealAnswersInPlace(results.results);
195
+
196
  // Show next button and hide submit button
197
  this.elements.submitBtn.style.display = 'none';
198
  this.elements.nextBtn.classList.remove('hidden');
 
220
  try {
221
  // Show loading immediately with specific message
222
  this.showLoading(true, 'Loading passages...');
223
+
224
+ // Clear chat history when starting new round
225
  this.chatUI.clearChatHistory();
226
+
227
  // Always show loading for at least 1 second for smooth UX
228
  const startTime = Date.now();
229
+
230
+ // Load next round
231
+ const roundData = await this.game.nextRound();
232
+
 
 
 
 
 
 
 
233
  // Ensure loading is shown for at least half a second
234
  const elapsedTime = Date.now() - startTime;
235
  if (elapsedTime < 500) {
236
  await new Promise(resolve => setTimeout(resolve, 500 - elapsedTime));
237
  }
238
+
239
  this.displayRound(roundData);
240
  this.resetUI();
241
  this.showLoading(false);
242
  } catch (error) {
243
+ console.error('Error loading next round:', error);
244
+ this.showError('Could not load next round. Please try again.');
245
  }
246
  }
247
 
 
337
  this.elements.loading.classList.remove('hidden');
338
  this.elements.gameArea.classList.add('hidden');
339
  }
340
+
341
+ checkForHighScore() {
342
+ // Check if current score qualifies for leaderboard
343
+ if (this.game.checkForHighScore()) {
344
+ const rank = this.game.getHighScoreRank();
345
+ const stats = this.game.leaderboardService.getStats();
346
+ const profile = this.game.leaderboardService.getPlayerProfile();
347
+
348
+ // If player hasn't entered initials yet, show initials entry
349
+ if (!profile.hasEnteredInitials) {
350
+ this.leaderboardUI.showInitialsEntry(
351
+ stats.highestLevel,
352
+ stats.roundAtHighestLevel,
353
+ rank,
354
+ (initials) => {
355
+ // Save to leaderboard
356
+ const finalRank = this.game.addToLeaderboard(initials);
357
+ console.log(`Added to leaderboard at rank ${finalRank}`);
358
+ }
359
+ );
360
+ } else {
361
+ // Update existing entry
362
+ const finalRank = this.game.addToLeaderboard(profile.initials);
363
+ console.log(`Updated leaderboard entry at rank ${finalRank}`);
364
+ }
365
+ }
366
+ }
367
  }
368
 
369
  // Initialize the app when DOM is loaded
src/clozeGameEngine.js CHANGED
@@ -2,6 +2,7 @@
2
  import bookDataService from './bookDataService.js';
3
  import { AIService } from './aiService.js';
4
  import ChatService from './conversationManager.js';
 
5
 
6
  const aiService = new AIService();
7
 
@@ -19,16 +20,10 @@ class ClozeGame {
19
  this.hints = [];
20
  this.chatService = new ChatService(aiService);
21
  this.lastResults = null; // Store results for answer revelation
22
- this.roundResults = []; // Store results for both passages in current round
23
-
24
- // Two-passage system properties
25
- this.currentBooks = []; // Array of two books per round
26
- this.passages = []; // Array of two passages per round
27
- this.currentPassageIndex = 0; // 0 for first passage, 1 for second
28
-
29
- // Level progression tracking
30
- this.passagesPassedAtCurrentLevel = 0; // Track successful passages at current level (not rounds)
31
- console.log('🎮 GAME ENGINE INITIALIZED - Starting at Level 1, Passages passed: 0');
32
  }
33
 
34
  // --- User-visible framing helpers ---
@@ -42,16 +37,13 @@ class ClozeGame {
42
  return {
43
  round: this.currentRound,
44
  level: this.currentLevel,
45
- passageNumber: this.currentPassageIndex + 1,
46
- totalPassages: 2,
47
- blanksPerPassage: this.getBlanksPerPassage(),
48
- passagesPassedAtCurrentLevel: this.passagesPassedAtCurrentLevel
49
  };
50
  }
51
 
52
  formatProgressText(snapshot = this.getProgressSnapshot()) {
53
  const blanksLabel = `${snapshot.blanksPerPassage} blank${snapshot.blanksPerPassage > 1 ? 's' : ''}`;
54
- return `Level ${snapshot.level} • Passage ${snapshot.passageNumber}/${snapshot.totalPassages} • ${blanksLabel}`;
55
  }
56
 
57
  formatAdvancementText({ passed, correctCount, requiredCorrect, justAdvancedLevel }) {
@@ -59,11 +51,7 @@ class ClozeGame {
59
  if (justAdvancedLevel) {
60
  return `✓ Passed • Level up! Welcome to Level ${this.currentLevel}`;
61
  }
62
- const needed = Math.max(0, 2 - this.passagesPassedAtCurrentLevel);
63
- if (needed === 0) {
64
- return `✓ Passed`;
65
- }
66
- return `✓ Passed • ${needed} more passage${needed > 1 ? 's' : ''} to reach Level ${this.currentLevel + 1}`;
67
  }
68
  return `Try again • Need ${requiredCorrect}/${this.blanks.length} correct (you got ${correctCount})`;
69
  }
@@ -80,54 +68,27 @@ class ClozeGame {
80
 
81
  async startNewRound() {
82
  try {
83
- console.log(`🎲 STARTING NEW ROUND - Round: ${this.currentRound}, Level: ${this.currentLevel}, Progress: ${this.passagesPassedAtCurrentLevel}/2`);
84
- // Get two books for this round based on current level criteria
85
- const book1 = await bookDataService.getBookByLevelCriteria(this.currentLevel);
86
- const book2 = await bookDataService.getBookByLevelCriteria(this.currentLevel);
87
-
88
- // Extract passages from both books
89
- const passage1 = this.extractCoherentPassage(book1.text);
90
- const passage2 = this.extractCoherentPassage(book2.text);
91
-
92
- // Store both books and passages
93
- this.currentBooks = [book1, book2];
94
- this.passages = [passage1.trim(), passage2.trim()];
95
- this.currentPassageIndex = 0;
96
-
97
- // Calculate blanks per passage based on level
98
- // Levels 1-5: 1 blank, 6-10: 2 blanks, 11+: 3 blanks
99
- const blanksPerPassage = this.getBlanksPerPassage();
100
-
101
- // Process both passages in a single API call
102
  try {
103
- const batchResult = await aiService.processBothPassages(
104
- passage1, book1, passage2, book2, blanksPerPassage, this.currentLevel
105
- );
106
-
107
- // Store the preprocessed data for both passages
108
- this.preprocessedData = batchResult;
109
-
110
- // Debug: Log what the AI returned
111
- console.log(`Level ${this.currentLevel}: Requested ${blanksPerPassage} blanks per passage`);
112
- console.log(`Passage 1 received ${batchResult.passage1.words.length} words:`, batchResult.passage1.words);
113
- console.log(`Passage 2 received ${batchResult.passage2.words.length} words:`, batchResult.passage2.words);
114
-
115
- // Set up first passage using preprocessed data
116
- this.currentBook = book1;
117
- this.originalText = this.passages[0];
118
- await this.createClozeTextFromPreprocessed(0);
119
- await this.generateContextualization();
120
-
121
- } catch (error) {
122
- console.warn('Batch processing failed, falling back to sequential:', error);
123
- // Fallback to sequential processing
124
- this.currentBook = book1;
125
- this.originalText = this.passages[0];
126
  await this.createClozeText();
127
  await new Promise(resolve => setTimeout(resolve, 1000));
128
  await this.generateContextualization();
 
 
 
129
  }
130
-
131
  const snapshot = this.getProgressSnapshot();
132
  return {
133
  title: this.currentBook.title,
@@ -136,8 +97,6 @@ class ClozeGame {
136
  blanks: this.blanks,
137
  contextualization: this.contextualization,
138
  hints: this.hints,
139
- passageNumber: 1,
140
- totalPassages: 2,
141
  progressText: this.formatProgressText(snapshot)
142
  };
143
  } catch (error) {
@@ -351,159 +310,6 @@ class ClozeGame {
351
  return passage.trim();
352
  }
353
 
354
- async createClozeTextFromPreprocessed(passageIndex) {
355
- // Use preprocessed word selection from batch API call
356
- const preprocessed = passageIndex === 0 ? this.preprocessedData.passage1 : this.preprocessedData.passage2;
357
- let selectedWords = preprocessed.words;
358
-
359
- // Calculate expected number of blanks based on level
360
- let expectedBlanks;
361
- if (this.currentLevel <= 5) {
362
- expectedBlanks = 1;
363
- } else if (this.currentLevel <= 10) {
364
- expectedBlanks = 2;
365
- } else {
366
- expectedBlanks = 3;
367
- }
368
-
369
- // Only use fallback if AI provided no words at all
370
- if (selectedWords.length === 0) {
371
- console.warn(`AI provided no words, using manual fallback selection`);
372
- const words = this.originalText.split(/\s+/);
373
- const fallbackWords = this.selectWordsManually(words, expectedBlanks);
374
- selectedWords = fallbackWords;
375
- console.log(`Fallback words:`, selectedWords);
376
- }
377
-
378
- // Limit selected words to expected number
379
- if (selectedWords.length > expectedBlanks) {
380
- console.log(`AI returned ${selectedWords.length} words but expected ${expectedBlanks}, limiting to ${expectedBlanks}`);
381
- selectedWords = selectedWords.slice(0, expectedBlanks);
382
- }
383
-
384
- // Split passage into words
385
- const words = this.originalText.split(/(\s+)/);
386
- const wordsOnly = words.filter(w => w.trim() !== '');
387
-
388
- // Find indices of selected words using flexible matching
389
- const selectedIndices = [];
390
- selectedWords.forEach((word, wordIdx) => {
391
- console.log(`Searching for word ${wordIdx + 1}/${selectedWords.length}: "${word}"`);
392
-
393
- // First try exact match (cleaned)
394
- let index = wordsOnly.findIndex((w, idx) => {
395
- const cleanW = w.replace(/[^\w]/g, '').toLowerCase();
396
- const cleanWord = word.replace(/[^\w]/g, '').toLowerCase();
397
- return cleanW === cleanWord && !selectedIndices.includes(idx);
398
- });
399
-
400
- if (index !== -1) {
401
- console.log(`✓ Found exact match: "${wordsOnly[index]}" at position ${index}`);
402
- } else {
403
- // Fallback to includes match if exact fails
404
- index = wordsOnly.findIndex((w, idx) =>
405
- w.toLowerCase().includes(word.toLowerCase()) && !selectedIndices.includes(idx)
406
- );
407
-
408
- if (index !== -1) {
409
- console.log(`✓ Found includes match: "${wordsOnly[index]}" at position ${index}`);
410
- } else {
411
- // Enhanced fallback: try base word matching (remove common suffixes)
412
- const baseWord = word.replace(/[^\w]/g, '').toLowerCase().replace(/(ed|ing|s|es|er|est)$/, '');
413
- if (baseWord.length > 2) {
414
- index = wordsOnly.findIndex((w, idx) => {
415
- const cleanW = w.replace(/[^\w]/g, '').toLowerCase();
416
- const baseW = cleanW.replace(/(ed|ing|s|es|er|est)$/, '');
417
- return baseW === baseWord && !selectedIndices.includes(idx);
418
- });
419
-
420
- if (index !== -1) {
421
- console.log(`✓ Found base word match: "${wordsOnly[index]}" at position ${index}`);
422
- }
423
- }
424
- }
425
- }
426
-
427
- if (index !== -1) {
428
- selectedIndices.push(index);
429
- } else {
430
- console.warn(`✗ Could not find word "${word}" in passage`);
431
- }
432
- });
433
-
434
- // Ensure we have at least the expected number of blanks
435
- if (selectedIndices.length < expectedBlanks) {
436
- console.warn(`Only found ${selectedIndices.length} words, need ${expectedBlanks}. Using fallback selection.`);
437
- const fallbackWords = this.selectWordsManually(wordsOnly, expectedBlanks - selectedIndices.length);
438
-
439
- // Add fallback word indices
440
- fallbackWords.forEach(fallbackWord => {
441
- const cleanFallback = fallbackWord.toLowerCase().replace(/[^\w]/g, '');
442
- const index = wordsOnly.findIndex((w, idx) => {
443
- const cleanW = w.replace(/[^\w]/g, '').toLowerCase();
444
- return cleanW === cleanFallback && !selectedIndices.includes(idx);
445
- });
446
- if (index !== -1) {
447
- selectedIndices.push(index);
448
- }
449
- });
450
- }
451
-
452
- // Create blanks
453
- this.blanks = [];
454
- this.hints = [];
455
- const clozeWords = [...wordsOnly];
456
-
457
- console.log(`Creating ${selectedIndices.length} blanks from ${selectedWords.length} selected words`);
458
-
459
- selectedIndices.forEach((wordIndex, blankIndex) => {
460
- const originalWord = wordsOnly[wordIndex];
461
- const cleanWord = originalWord.replace(/[^\w]/g, '');
462
-
463
- this.blanks.push({
464
- index: blankIndex,
465
- originalWord: cleanWord,
466
- wordIndex: wordIndex
467
- });
468
-
469
- // Initialize chat context for this word
470
- const wordContext = {
471
- originalWord: cleanWord,
472
- sentence: this.originalText,
473
- passage: this.originalText,
474
- bookTitle: this.currentBook.title,
475
- author: this.currentBook.author,
476
- year: this.currentBook.year,
477
- wordPosition: wordIndex,
478
- difficulty: this.calculateWordDifficulty(cleanWord, wordIndex, wordsOnly)
479
- };
480
-
481
- this.chatService.initializeWordContext(`blank_${blankIndex}`, wordContext);
482
-
483
- // Generate structural hint
484
- const hint = this.currentLevel <= 2
485
- ? `${cleanWord.length} letters, starts with "${cleanWord[0]}", ends with "${cleanWord[cleanWord.length - 1]}"`
486
- : `${cleanWord.length} letters, starts with "${cleanWord[0]}"`;
487
- this.hints.push({ index: blankIndex, hint });
488
-
489
- // Replace with placeholder
490
- clozeWords[wordIndex] = `___BLANK_${blankIndex}___`;
491
- });
492
-
493
- // Reconstruct text with original spacing
494
- let reconstructed = '';
495
- let wordIndex = 0;
496
- words.forEach(part => {
497
- if (part.trim() === '') {
498
- reconstructed += part;
499
- } else {
500
- reconstructed += clozeWords[wordIndex++];
501
- }
502
- });
503
-
504
- this.clozeText = reconstructed;
505
- this.userAnswers = new Array(this.blanks.length).fill('');
506
- }
507
 
508
  async createClozeText() {
509
  const words = this.originalText.split(' ');
@@ -761,7 +567,8 @@ class ClozeGame {
761
  try {
762
  this.contextualization = await aiService.generateContextualization(
763
  this.currentBook.title,
764
- this.currentBook.author
 
765
  );
766
  return this.contextualization;
767
  } catch (error) {
@@ -809,34 +616,30 @@ class ClozeGame {
809
 
810
  const scorePercentage = Math.round((correctCount / this.blanks.length) * 100);
811
  this.score = scorePercentage;
812
-
813
  // Calculate pass requirements based on number of blanks
814
  const totalBlanks = this.blanks.length;
815
  const requiredCorrect = this.calculateRequiredCorrect(totalBlanks);
816
  const passed = correctCount >= requiredCorrect;
817
-
818
- // Track successful passages for level advancement
 
 
 
819
  if (passed) {
820
- this.passagesPassedAtCurrentLevel++;
821
- console.log(`✅ PASSAGE PASSED - Level: ${this.currentLevel}, Passages passed at current level: ${this.passagesPassedAtCurrentLevel}/2`);
822
-
823
- // Advance level after 2 successful passages (not rounds)
824
- if (this.passagesPassedAtCurrentLevel >= 2) {
825
- const previousLevel = this.currentLevel;
826
- this.currentLevel++;
827
- this.passagesPassedAtCurrentLevel = 0; // Reset counter for new level
828
- console.log(`🎉 LEVEL ADVANCEMENT: ${previousLevel} → ${this.currentLevel} (counter reset to 0)`);
829
- } else {
830
- console.log(`📊 Progress: Need ${2 - this.passagesPassedAtCurrentLevel} more passage(s) to advance from level ${this.currentLevel}`);
831
- }
832
  } else {
833
- console.log(`❌ PASSAGE FAILED - Level: ${this.currentLevel}, Passages passed remains: ${this.passagesPassedAtCurrentLevel}/2`);
834
  }
835
 
836
- // Track if we just advanced levels
837
- const justAdvancedLevel = passed && this.passagesPassedAtCurrentLevel === 0 && this.currentLevel > 1;
838
-
839
  const snapshot = this.getProgressSnapshot();
 
 
 
 
 
840
  const resultsData = {
841
  correct: correctCount,
842
  total: this.blanks.length,
@@ -847,21 +650,18 @@ class ClozeGame {
847
  shouldRevealAnswers: !passed,
848
  requiredCorrect: requiredCorrect,
849
  currentLevel: this.currentLevel,
850
- passagesPassedAtCurrentLevel: this.passagesPassedAtCurrentLevel,
851
  justAdvancedLevel: justAdvancedLevel,
852
  round: snapshot.round,
853
- passageNumber: snapshot.passageNumber,
854
- totalPassages: snapshot.totalPassages,
855
  progressText: this.formatProgressText(snapshot),
856
- feedbackText: this.formatAdvancementText({ passed, correctCount, requiredCorrect, justAdvancedLevel }),
857
- nextActionText: snapshot.passageNumber === snapshot.totalPassages ? 'Next round' : 'Next passage'
858
  };
859
 
 
 
 
860
  // Store results for potential answer revelation
861
  this.lastResults = resultsData;
862
-
863
- // Store results for round-level tracking
864
- this.roundResults[this.currentPassageIndex] = resultsData;
865
 
866
  return resultsData;
867
  }
@@ -887,68 +687,19 @@ class ClozeGame {
887
  }));
888
  }
889
 
890
- async nextPassage() {
891
- try {
892
- // Move to the second passage in the current round
893
- if (this.currentPassageIndex === 0 && this.passages && this.passages.length > 1) {
894
- this.currentPassageIndex = 1;
895
- console.log(`📖 MOVING TO PASSAGE 2/2 in Round ${this.currentRound} - Level: ${this.currentLevel}, Progress: ${this.passagesPassedAtCurrentLevel}/2`);
896
- this.currentBook = this.currentBooks[1];
897
- this.originalText = this.passages[1];
898
-
899
- // Clear chat conversations for new passage
900
- this.chatService.clearConversations();
901
-
902
- // Clear last results (but keep roundResults for level advancement)
903
- this.lastResults = null;
904
-
905
- // Use preprocessed data if available
906
- if (this.preprocessedData && this.preprocessedData.passage2) {
907
- await this.createClozeTextFromPreprocessed(1);
908
- await this.generateContextualization();
909
- } else {
910
- // Fallback to sequential processing
911
- await this.createClozeText();
912
- await new Promise(resolve => setTimeout(resolve, 1000));
913
- await this.generateContextualization();
914
- }
915
-
916
- const snapshot = this.getProgressSnapshot();
917
- return {
918
- title: this.currentBook.title,
919
- author: this.currentBook.author,
920
- text: this.clozeText,
921
- blanks: this.blanks,
922
- contextualization: this.contextualization,
923
- hints: this.hints,
924
- passageNumber: 2,
925
- totalPassages: 2,
926
- progressText: this.formatProgressText(snapshot)
927
- };
928
- } else {
929
- // If we're already on the second passage, move to next round
930
- return this.nextRound();
931
- }
932
- } catch (error) {
933
- console.error('Error loading next passage:', error);
934
- throw error;
935
- }
936
- }
937
-
938
  nextRound() {
939
  // Always increment round counter
940
  this.currentRound++;
941
- console.log(`🔄 NEW ROUND ${this.currentRound} - Level: ${this.currentLevel}, Passages passed at current level: ${this.passagesPassedAtCurrentLevel}/2`);
942
-
943
- // Level advancement is now handled in submitAnswers() based on individual passages
944
-
945
  // Clear chat conversations for new round
946
  this.chatService.clearConversations();
947
-
948
  // Clear results since we're moving to new round
949
  this.lastResults = null;
950
- this.roundResults = [];
951
-
952
  return this.startNewRound();
953
  }
954
 
@@ -1017,30 +768,63 @@ class ClozeGame {
1017
  // Enhanced render method to include chat buttons
1018
  renderClozeTextWithChat() {
1019
  let html = this.clozeText;
1020
-
1021
  this.blanks.forEach((blank, index) => {
1022
  const chatButtonId = `chat-btn-${index}`;
1023
  const inputHtml = `
1024
  <span class="inline-flex items-center">
1025
- <input type="text"
1026
- class="cloze-input"
1027
- data-blank-index="${index}"
1028
  placeholder="${'_'.repeat(Math.max(3, blank.originalWord.length))}"
1029
  style="width: ${Math.max(50, blank.originalWord.length * 10)}px;">
1030
- <button id="${chatButtonId}"
1031
- class="chat-button text-blue-500 hover:text-blue-700"
1032
  data-blank-index="${index}"
1033
  title="Ask question about this word"
1034
  style="font-size: 1.5rem; line-height: 1;">
1035
  💬
1036
  </button>
1037
  </span>`;
1038
-
1039
  html = html.replace(`___BLANK_${index}___`, inputHtml);
1040
  });
1041
 
1042
  return html;
1043
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1044
  }
1045
 
1046
  export default ClozeGame;
 
2
  import bookDataService from './bookDataService.js';
3
  import { AIService } from './aiService.js';
4
  import ChatService from './conversationManager.js';
5
+ import { LeaderboardService } from './leaderboardService.js';
6
 
7
  const aiService = new AIService();
8
 
 
20
  this.hints = [];
21
  this.chatService = new ChatService(aiService);
22
  this.lastResults = null; // Store results for answer revelation
23
+ this.leaderboardService = new LeaderboardService();
24
+ this.passagesPassedAtCurrentLevel = 0; // Track progress toward level advancement
25
+
26
+ console.log('🎮 GAME ENGINE INITIALIZED - Starting at Level 1, Round 1');
 
 
 
 
 
 
27
  }
28
 
29
  // --- User-visible framing helpers ---
 
37
  return {
38
  round: this.currentRound,
39
  level: this.currentLevel,
40
+ blanksPerPassage: this.getBlanksPerPassage()
 
 
 
41
  };
42
  }
43
 
44
  formatProgressText(snapshot = this.getProgressSnapshot()) {
45
  const blanksLabel = `${snapshot.blanksPerPassage} blank${snapshot.blanksPerPassage > 1 ? 's' : ''}`;
46
+ return `Level ${snapshot.level} • ${blanksLabel}`;
47
  }
48
 
49
  formatAdvancementText({ passed, correctCount, requiredCorrect, justAdvancedLevel }) {
 
51
  if (justAdvancedLevel) {
52
  return `✓ Passed • Level up! Welcome to Level ${this.currentLevel}`;
53
  }
54
+ return `✓ Passed Advancing to next level!`;
 
 
 
 
55
  }
56
  return `Try again • Need ${requiredCorrect}/${this.blanks.length} correct (you got ${correctCount})`;
57
  }
 
68
 
69
  async startNewRound() {
70
  try {
71
+ console.log(`🎲 STARTING NEW ROUND - Round: ${this.currentRound}, Level: ${this.currentLevel}`);
72
+ // Get one book for this round based on current level criteria
73
+ const book = await bookDataService.getBookByLevelCriteria(this.currentLevel);
74
+
75
+ // Extract passage from book
76
+ const passage = this.extractCoherentPassage(book.text);
77
+
78
+ // Store book and passage
79
+ this.currentBook = book;
80
+ this.originalText = passage.trim();
81
+
82
+ // Create cloze text using AI
 
 
 
 
 
 
 
83
  try {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
84
  await this.createClozeText();
85
  await new Promise(resolve => setTimeout(resolve, 1000));
86
  await this.generateContextualization();
87
+ } catch (error) {
88
+ console.warn('AI processing failed:', error);
89
+ throw error;
90
  }
91
+
92
  const snapshot = this.getProgressSnapshot();
93
  return {
94
  title: this.currentBook.title,
 
97
  blanks: this.blanks,
98
  contextualization: this.contextualization,
99
  hints: this.hints,
 
 
100
  progressText: this.formatProgressText(snapshot)
101
  };
102
  } catch (error) {
 
310
  return passage.trim();
311
  }
312
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
313
 
314
  async createClozeText() {
315
  const words = this.originalText.split(' ');
 
567
  try {
568
  this.contextualization = await aiService.generateContextualization(
569
  this.currentBook.title,
570
+ this.currentBook.author,
571
+ this.originalText
572
  );
573
  return this.contextualization;
574
  } catch (error) {
 
616
 
617
  const scorePercentage = Math.round((correctCount / this.blanks.length) * 100);
618
  this.score = scorePercentage;
619
+
620
  // Calculate pass requirements based on number of blanks
621
  const totalBlanks = this.blanks.length;
622
  const requiredCorrect = this.calculateRequiredCorrect(totalBlanks);
623
  const passed = correctCount >= requiredCorrect;
624
+
625
+ // Track if we're advancing level
626
+ const justAdvancedLevel = passed;
627
+
628
+ // Advance level on pass
629
  if (passed) {
630
+ const previousLevel = this.currentLevel;
631
+ this.currentLevel++;
632
+ console.log(`✅ ROUND PASSED - Level advancement: ${previousLevel} → ${this.currentLevel}`);
 
 
 
 
 
 
 
 
 
633
  } else {
634
+ console.log(`❌ ROUND FAILED - Staying at Level: ${this.currentLevel}`);
635
  }
636
 
 
 
 
637
  const snapshot = this.getProgressSnapshot();
638
+
639
+ // Update passage tracking
640
+ const stats = this.leaderboardService.getStats();
641
+ const totalPassagesPassed = passed ? (stats.totalPassagesPassed + 1) : stats.totalPassagesPassed;
642
+
643
  const resultsData = {
644
  correct: correctCount,
645
  total: this.blanks.length,
 
650
  shouldRevealAnswers: !passed,
651
  requiredCorrect: requiredCorrect,
652
  currentLevel: this.currentLevel,
 
653
  justAdvancedLevel: justAdvancedLevel,
654
  round: snapshot.round,
655
+ passagesPassed: totalPassagesPassed,
 
656
  progressText: this.formatProgressText(snapshot),
657
+ feedbackText: this.formatAdvancementText({ passed, correctCount, requiredCorrect, justAdvancedLevel })
 
658
  };
659
 
660
+ // Update leaderboard stats
661
+ this.leaderboardService.updateStats(resultsData);
662
+
663
  // Store results for potential answer revelation
664
  this.lastResults = resultsData;
 
 
 
665
 
666
  return resultsData;
667
  }
 
687
  }));
688
  }
689
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
690
  nextRound() {
691
  // Always increment round counter
692
  this.currentRound++;
693
+ console.log(`🔄 NEW ROUND ${this.currentRound} - Level: ${this.currentLevel}`);
694
+
695
+ // Level advancement is now handled in submitAnswers() based on pass/fail
696
+
697
  // Clear chat conversations for new round
698
  this.chatService.clearConversations();
699
+
700
  // Clear results since we're moving to new round
701
  this.lastResults = null;
702
+
 
703
  return this.startNewRound();
704
  }
705
 
 
768
  // Enhanced render method to include chat buttons
769
  renderClozeTextWithChat() {
770
  let html = this.clozeText;
771
+
772
  this.blanks.forEach((blank, index) => {
773
  const chatButtonId = `chat-btn-${index}`;
774
  const inputHtml = `
775
  <span class="inline-flex items-center">
776
+ <input type="text"
777
+ class="cloze-input"
778
+ data-blank-index="${index}"
779
  placeholder="${'_'.repeat(Math.max(3, blank.originalWord.length))}"
780
  style="width: ${Math.max(50, blank.originalWord.length * 10)}px;">
781
+ <button id="${chatButtonId}"
782
+ class="chat-button text-blue-500 hover:text-blue-700"
783
  data-blank-index="${index}"
784
  title="Ask question about this word"
785
  style="font-size: 1.5rem; line-height: 1;">
786
  💬
787
  </button>
788
  </span>`;
789
+
790
  html = html.replace(`___BLANK_${index}___`, inputHtml);
791
  });
792
 
793
  return html;
794
  }
795
+
796
+ // Leaderboard integration methods
797
+ checkForHighScore() {
798
+ const stats = this.leaderboardService.getStats();
799
+ return this.leaderboardService.qualifiesForLeaderboard(
800
+ stats.highestLevel,
801
+ stats.roundAtHighestLevel,
802
+ stats.totalPassagesPassed
803
+ );
804
+ }
805
+
806
+ getHighScoreRank() {
807
+ const stats = this.leaderboardService.getStats();
808
+ return this.leaderboardService.getRankForScore(
809
+ stats.highestLevel,
810
+ stats.roundAtHighestLevel,
811
+ stats.totalPassagesPassed
812
+ );
813
+ }
814
+
815
+ addToLeaderboard(initials) {
816
+ const stats = this.leaderboardService.getStats();
817
+ return this.leaderboardService.addEntry(
818
+ initials,
819
+ stats.highestLevel,
820
+ stats.roundAtHighestLevel,
821
+ stats.totalPassagesPassed
822
+ );
823
+ }
824
+
825
+ getLeaderboardStats() {
826
+ return this.leaderboardService.getPlayerStats();
827
+ }
828
  }
829
 
830
  export default ClozeGame;
src/leaderboardService.js ADDED
@@ -0,0 +1,362 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Leaderboard Service
3
+ * Manages high scores, player stats, and localStorage persistence
4
+ * Following arcade conventions with 3-letter initials and top 10 tracking
5
+ */
6
+
7
+ export class LeaderboardService {
8
+ constructor() {
9
+ this.storageKeys = {
10
+ leaderboard: 'cloze-reader-leaderboard',
11
+ player: 'cloze-reader-player',
12
+ stats: 'cloze-reader-stats'
13
+ };
14
+
15
+ this.maxEntries = 10;
16
+ this.initializeStorage();
17
+ }
18
+
19
+ /**
20
+ * Initialize localStorage with default values if needed
21
+ */
22
+ initializeStorage() {
23
+ if (!this.getLeaderboard()) {
24
+ this.saveLeaderboard([]);
25
+ }
26
+
27
+ if (!this.getPlayerProfile()) {
28
+ this.savePlayerProfile({
29
+ initials: null,
30
+ hasEnteredInitials: false,
31
+ gamesPlayed: 0,
32
+ lastPlayed: null
33
+ });
34
+ }
35
+
36
+ if (!this.getStats()) {
37
+ this.saveStats(this.createEmptyStats());
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Create empty stats object
43
+ */
44
+ createEmptyStats() {
45
+ return {
46
+ highestLevel: 1,
47
+ roundAtHighestLevel: 1,
48
+ totalPassagesPassed: 0,
49
+ totalPassagesAttempted: 0,
50
+ longestStreak: 0,
51
+ currentStreak: 0,
52
+ perfectRounds: 0,
53
+ totalCorrectWords: 0,
54
+ uniqueWordsCorrect: new Set(),
55
+ gamesPlayed: 0,
56
+ lastPlayed: null
57
+ };
58
+ }
59
+
60
+ /**
61
+ * Get leaderboard from localStorage
62
+ */
63
+ getLeaderboard() {
64
+ try {
65
+ const data = localStorage.getItem(this.storageKeys.leaderboard);
66
+ return data ? JSON.parse(data) : null;
67
+ } catch (e) {
68
+ console.error('Error reading leaderboard:', e);
69
+ return [];
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Save leaderboard to localStorage
75
+ */
76
+ saveLeaderboard(entries) {
77
+ try {
78
+ localStorage.setItem(this.storageKeys.leaderboard, JSON.stringify(entries));
79
+ } catch (e) {
80
+ console.error('Error saving leaderboard:', e);
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Get player profile from localStorage
86
+ */
87
+ getPlayerProfile() {
88
+ try {
89
+ const data = localStorage.getItem(this.storageKeys.player);
90
+ return data ? JSON.parse(data) : null;
91
+ } catch (e) {
92
+ console.error('Error reading player profile:', e);
93
+ return null;
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Save player profile to localStorage
99
+ */
100
+ savePlayerProfile(profile) {
101
+ try {
102
+ localStorage.setItem(this.storageKeys.player, JSON.stringify(profile));
103
+ } catch (e) {
104
+ console.error('Error saving player profile:', e);
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Get stats from localStorage
110
+ */
111
+ getStats() {
112
+ try {
113
+ const data = localStorage.getItem(this.storageKeys.stats);
114
+ if (!data) return null;
115
+
116
+ const stats = JSON.parse(data);
117
+ // Convert uniqueWordsCorrect back to Set
118
+ if (stats.uniqueWordsCorrect && Array.isArray(stats.uniqueWordsCorrect)) {
119
+ stats.uniqueWordsCorrect = new Set(stats.uniqueWordsCorrect);
120
+ } else {
121
+ stats.uniqueWordsCorrect = new Set();
122
+ }
123
+ return stats;
124
+ } catch (e) {
125
+ console.error('Error reading stats:', e);
126
+ return null;
127
+ }
128
+ }
129
+
130
+ /**
131
+ * Save stats to localStorage
132
+ */
133
+ saveStats(stats) {
134
+ try {
135
+ // Convert Set to Array for JSON serialization
136
+ const statsToSave = {
137
+ ...stats,
138
+ uniqueWordsCorrect: Array.from(stats.uniqueWordsCorrect || [])
139
+ };
140
+ localStorage.setItem(this.storageKeys.stats, JSON.stringify(statsToSave));
141
+ } catch (e) {
142
+ console.error('Error saving stats:', e);
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Validate and sanitize initials (3 letters, A-Z only)
148
+ */
149
+ validateInitials(initials) {
150
+ if (!initials || typeof initials !== 'string') {
151
+ return false;
152
+ }
153
+
154
+ const sanitized = initials.toUpperCase().replace(/[^A-Z]/g, '');
155
+ return sanitized.length === 3 ? sanitized : false;
156
+ }
157
+
158
+ /**
159
+ * Sort leaderboard entries
160
+ * Primary: Level (desc), Secondary: Round (desc), Tertiary: Passages passed (desc)
161
+ */
162
+ sortLeaderboard(entries) {
163
+ return entries.sort((a, b) => {
164
+ // Primary: Level (higher is better)
165
+ if (b.level !== a.level) {
166
+ return b.level - a.level;
167
+ }
168
+
169
+ // Secondary: Round at that level (higher is better)
170
+ if (b.round !== a.round) {
171
+ return b.round - a.round;
172
+ }
173
+
174
+ // Tertiary: Total passages passed (higher is better)
175
+ if (b.passagesPassed !== a.passagesPassed) {
176
+ return b.passagesPassed - a.passagesPassed;
177
+ }
178
+
179
+ // Quaternary: Date (newer is better)
180
+ return new Date(b.date) - new Date(a.date);
181
+ });
182
+ }
183
+
184
+ /**
185
+ * Check if a score qualifies for the leaderboard
186
+ */
187
+ qualifiesForLeaderboard(level, round, passagesPassed) {
188
+ const leaderboard = this.getLeaderboard();
189
+
190
+ // If leaderboard isn't full, always qualifies
191
+ if (leaderboard.length < this.maxEntries) {
192
+ return true;
193
+ }
194
+
195
+ // Check if better than lowest entry
196
+ const lowestEntry = leaderboard[leaderboard.length - 1];
197
+
198
+ if (level > lowestEntry.level) return true;
199
+ if (level === lowestEntry.level && round > lowestEntry.round) return true;
200
+ if (level === lowestEntry.level && round === lowestEntry.round && passagesPassed > lowestEntry.passagesPassed) return true;
201
+
202
+ return false;
203
+ }
204
+
205
+ /**
206
+ * Get the rank position for a score (1-10, or null if doesn't qualify)
207
+ */
208
+ getRankForScore(level, round, passagesPassed) {
209
+ if (!this.qualifiesForLeaderboard(level, round, passagesPassed)) {
210
+ return null;
211
+ }
212
+
213
+ const leaderboard = this.getLeaderboard();
214
+ const tempEntry = { level, round, passagesPassed, date: new Date().toISOString() };
215
+ const tempLeaderboard = [...leaderboard, tempEntry];
216
+ const sorted = this.sortLeaderboard(tempLeaderboard);
217
+
218
+ return sorted.findIndex(entry => entry === tempEntry) + 1;
219
+ }
220
+
221
+ /**
222
+ * Add a new entry to the leaderboard
223
+ */
224
+ addEntry(initials, level, round, passagesPassed) {
225
+ const validInitials = this.validateInitials(initials);
226
+ if (!validInitials) {
227
+ console.error('Invalid initials:', initials);
228
+ return false;
229
+ }
230
+
231
+ const leaderboard = this.getLeaderboard();
232
+ const newEntry = {
233
+ initials: validInitials,
234
+ level,
235
+ round,
236
+ passagesPassed,
237
+ date: new Date().toISOString()
238
+ };
239
+
240
+ leaderboard.push(newEntry);
241
+ const sorted = this.sortLeaderboard(leaderboard);
242
+
243
+ // Keep only top 10
244
+ const trimmed = sorted.slice(0, this.maxEntries);
245
+ this.saveLeaderboard(trimmed);
246
+
247
+ return sorted.findIndex(entry => entry === newEntry) + 1; // Return rank
248
+ }
249
+
250
+ /**
251
+ * Update session stats after a passage attempt
252
+ */
253
+ updateStats(data) {
254
+ const stats = this.getStats() || this.createEmptyStats();
255
+
256
+ stats.totalPassagesAttempted++;
257
+
258
+ if (data.passed) {
259
+ stats.totalPassagesPassed++;
260
+ stats.currentStreak++;
261
+ stats.longestStreak = Math.max(stats.longestStreak, stats.currentStreak);
262
+ } else {
263
+ stats.currentStreak = 0;
264
+ }
265
+
266
+ // Track highest level reached
267
+ if (data.currentLevel > stats.highestLevel) {
268
+ stats.highestLevel = data.currentLevel;
269
+ stats.roundAtHighestLevel = data.round;
270
+ } else if (data.currentLevel === stats.highestLevel) {
271
+ stats.roundAtHighestLevel = Math.max(stats.roundAtHighestLevel, data.round);
272
+ }
273
+
274
+ // Track correct words
275
+ if (data.results) {
276
+ data.results.forEach(result => {
277
+ if (result.isCorrect) {
278
+ stats.totalCorrectWords++;
279
+ const word = result.correctAnswer.toLowerCase();
280
+ stats.uniqueWordsCorrect.add(word);
281
+ }
282
+ });
283
+ }
284
+
285
+ // Track perfect rounds (if both passages in round were 100%)
286
+ if (data.percentage === 100 && data.passagesPassed % 2 === 0) {
287
+ stats.perfectRounds++;
288
+ }
289
+
290
+ stats.lastPlayed = new Date().toISOString();
291
+
292
+ this.saveStats(stats);
293
+ return stats;
294
+ }
295
+
296
+ /**
297
+ * Get formatted leaderboard for display
298
+ */
299
+ getFormattedLeaderboard() {
300
+ const leaderboard = this.getLeaderboard();
301
+ const player = this.getPlayerProfile();
302
+ const stats = this.getStats();
303
+
304
+ return {
305
+ entries: leaderboard.map((entry, index) => ({
306
+ rank: index + 1,
307
+ initials: entry.initials,
308
+ level: entry.level,
309
+ round: entry.round,
310
+ passagesPassed: entry.passagesPassed,
311
+ date: entry.date,
312
+ isPlayer: player && player.initials === entry.initials
313
+ })),
314
+ playerBest: stats ? {
315
+ level: stats.highestLevel,
316
+ round: stats.roundAtHighestLevel,
317
+ passagesPassed: stats.totalPassagesPassed
318
+ } : null,
319
+ playerInitials: player ? player.initials : null
320
+ };
321
+ }
322
+
323
+ /**
324
+ * Reset all leaderboard data (for testing or user request)
325
+ */
326
+ resetAll() {
327
+ this.saveLeaderboard([]);
328
+ this.savePlayerProfile({
329
+ initials: null,
330
+ hasEnteredInitials: false,
331
+ gamesPlayed: 0,
332
+ lastPlayed: null
333
+ });
334
+ this.saveStats(this.createEmptyStats());
335
+ }
336
+
337
+ /**
338
+ * Get player stats summary
339
+ */
340
+ getPlayerStats() {
341
+ const stats = this.getStats() || this.createEmptyStats();
342
+ const profile = this.getPlayerProfile();
343
+
344
+ return {
345
+ initials: profile?.initials || '---',
346
+ highestLevel: stats.highestLevel,
347
+ roundAtHighestLevel: stats.roundAtHighestLevel,
348
+ totalPassagesPassed: stats.totalPassagesPassed,
349
+ totalPassagesAttempted: stats.totalPassagesAttempted,
350
+ successRate: stats.totalPassagesAttempted > 0
351
+ ? Math.round((stats.totalPassagesPassed / stats.totalPassagesAttempted) * 100)
352
+ : 0,
353
+ longestStreak: stats.longestStreak,
354
+ currentStreak: stats.currentStreak,
355
+ perfectRounds: stats.perfectRounds,
356
+ totalCorrectWords: stats.totalCorrectWords,
357
+ uniqueWords: stats.uniqueWordsCorrect.size,
358
+ gamesPlayed: stats.gamesPlayed,
359
+ lastPlayed: stats.lastPlayed
360
+ };
361
+ }
362
+ }
src/leaderboardUI.js ADDED
@@ -0,0 +1,433 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Leaderboard UI
3
+ * Modal display and initials entry interface
4
+ * Following arcade conventions with vintage aesthetic
5
+ */
6
+
7
+ export class LeaderboardUI {
8
+ constructor(leaderboardService) {
9
+ this.service = leaderboardService;
10
+ this.modal = null;
11
+ this.initialsModal = null;
12
+ this.currentSlot = 0;
13
+ this.initials = ['A', 'A', 'A'];
14
+ this.onInitialsSubmit = null;
15
+ }
16
+
17
+ /**
18
+ * Show the leaderboard modal
19
+ */
20
+ show() {
21
+ // Remove existing modal if any
22
+ this.hide();
23
+
24
+ const data = this.service.getFormattedLeaderboard();
25
+ const playerStats = this.service.getPlayerStats();
26
+
27
+ // Create modal HTML
28
+ this.modal = document.createElement('div');
29
+ this.modal.className = 'leaderboard-overlay';
30
+ this.modal.innerHTML = `
31
+ <div class="leaderboard-modal">
32
+ <div class="leaderboard-header">
33
+ <h2 class="leaderboard-title">🏆 HIGH SCORES 🏆</h2>
34
+ <button class="leaderboard-close" aria-label="Close leaderboard">×</button>
35
+ </div>
36
+
37
+ <div class="leaderboard-content">
38
+ <div class="leaderboard-list">
39
+ ${this.generateLeaderboardHTML(data.entries, data.playerInitials)}
40
+ </div>
41
+
42
+ ${playerStats.highestLevel > 1 ? `
43
+ <div class="leaderboard-player-stats">
44
+ <div class="player-best">
45
+ Your Best: <span class="highlight">Level ${playerStats.highestLevel} (Round ${playerStats.roundAtHighestLevel})</span>
46
+ </div>
47
+ <div class="player-stats-details">
48
+ <div>Passages: ${playerStats.totalPassagesPassed}/${playerStats.totalPassagesAttempted} (${playerStats.successRate}%)</div>
49
+ <div>Longest Streak: ${playerStats.longestStreak} • Perfect Rounds: ${playerStats.perfectRounds}</div>
50
+ </div>
51
+ </div>
52
+ ` : ''}
53
+ </div>
54
+ </div>
55
+ `;
56
+
57
+ document.body.appendChild(this.modal);
58
+
59
+ // Animate in
60
+ requestAnimationFrame(() => {
61
+ this.modal.classList.add('visible');
62
+ });
63
+
64
+ // Add event listeners
65
+ this.modal.querySelector('.leaderboard-close').addEventListener('click', () => this.hide());
66
+ this.modal.addEventListener('click', (e) => {
67
+ if (e.target === this.modal) {
68
+ this.hide();
69
+ }
70
+ });
71
+
72
+ // ESC key to close
73
+ this.escHandler = (e) => {
74
+ if (e.key === 'Escape') {
75
+ this.hide();
76
+ }
77
+ };
78
+ document.addEventListener('keydown', this.escHandler);
79
+ }
80
+
81
+ /**
82
+ * Generate HTML for leaderboard entries
83
+ */
84
+ generateLeaderboardHTML(entries, playerInitials) {
85
+ if (entries.length === 0) {
86
+ return `
87
+ <div class="leaderboard-empty">
88
+ <p>No high scores yet!</p>
89
+ <p class="text-sm">Be the first to reach Level 2!</p>
90
+ </div>
91
+ `;
92
+ }
93
+
94
+ return entries.map(entry => {
95
+ const rankClass = this.getRankClass(entry.rank);
96
+ const isPlayer = entry.initials === playerInitials;
97
+ const playerClass = isPlayer ? 'player-entry' : '';
98
+
99
+ return `
100
+ <div class="leaderboard-entry ${rankClass} ${playerClass}">
101
+ <span class="entry-rank">#${entry.rank}</span>
102
+ <span class="entry-initials">${entry.initials}</span>
103
+ <span class="entry-score">Level ${entry.level} <span class="entry-round">(Round ${entry.round})</span></span>
104
+ </div>
105
+ `;
106
+ }).join('');
107
+ }
108
+
109
+ /**
110
+ * Get CSS class for rank-based styling
111
+ */
112
+ getRankClass(rank) {
113
+ if (rank === 1) return 'rank-gold';
114
+ if (rank === 2 || rank === 3) return 'rank-silver';
115
+ return 'rank-standard';
116
+ }
117
+
118
+ /**
119
+ * Hide the leaderboard modal
120
+ */
121
+ hide() {
122
+ if (this.modal) {
123
+ this.modal.classList.remove('visible');
124
+ setTimeout(() => {
125
+ if (this.modal && this.modal.parentNode) {
126
+ this.modal.parentNode.removeChild(this.modal);
127
+ }
128
+ this.modal = null;
129
+ }, 300);
130
+ }
131
+
132
+ if (this.escHandler) {
133
+ document.removeEventListener('keydown', this.escHandler);
134
+ this.escHandler = null;
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Show initials entry screen for new high score
140
+ */
141
+ showInitialsEntry(level, round, rank, onSubmit) {
142
+ // Store callback
143
+ this.onInitialsSubmit = onSubmit;
144
+
145
+ // Reset initials state
146
+ this.currentSlot = 0;
147
+
148
+ // Get existing player initials if available
149
+ const profile = this.service.getPlayerProfile();
150
+ if (profile && profile.initials) {
151
+ this.initials = profile.initials.split('');
152
+ } else {
153
+ this.initials = ['A', 'A', 'A'];
154
+ }
155
+
156
+ // Remove existing modal
157
+ this.hideInitialsEntry();
158
+
159
+ // Create modal HTML
160
+ this.initialsModal = document.createElement('div');
161
+ this.initialsModal.className = 'leaderboard-overlay initials-overlay';
162
+ this.initialsModal.innerHTML = `
163
+ <div class="initials-modal">
164
+ <div class="initials-header">
165
+ <h2 class="initials-title">🎉 NEW HIGH SCORE! 🎉</h2>
166
+ <div class="initials-achievement">
167
+ You reached <span class="highlight">Level ${level}</span>!
168
+ <br>
169
+ <span class="rank-text">${this.getRankText(rank)}</span>
170
+ </div>
171
+ </div>
172
+
173
+ <div class="initials-content">
174
+ <p class="initials-prompt">Enter your initials:</p>
175
+
176
+ <div class="initials-slots">
177
+ ${this.initials.map((letter, index) => `
178
+ <div class="initial-slot ${index === 0 ? 'active' : ''}" data-slot="${index}">
179
+ <div class="slot-letter">${letter}</div>
180
+ <div class="slot-arrows">
181
+ <button class="arrow-up" data-slot="${index}" data-direction="up" aria-label="Increase letter">▲</button>
182
+ <button class="arrow-down" data-slot="${index}" data-direction="down" aria-label="Decrease letter">▼</button>
183
+ </div>
184
+ </div>
185
+ `).join('')}
186
+ </div>
187
+
188
+ <div class="initials-instructions">
189
+ <p>Use arrow keys ↑↓ to change letters</p>
190
+ <p>Press Tab or ←→ to move between slots</p>
191
+ <p>Press Enter to confirm</p>
192
+ </div>
193
+
194
+ <button class="initials-submit typewriter-button">
195
+ SUBMIT
196
+ </button>
197
+ </div>
198
+ </div>
199
+ `;
200
+
201
+ document.body.appendChild(this.initialsModal);
202
+
203
+ // Animate in
204
+ requestAnimationFrame(() => {
205
+ this.initialsModal.classList.add('visible');
206
+ });
207
+
208
+ // Add event listeners
209
+ this.setupInitialsEventListeners();
210
+ }
211
+
212
+ /**
213
+ * Get rank description text
214
+ */
215
+ getRankText(rank) {
216
+ const ordinal = this.getOrdinal(rank);
217
+ if (rank === 1) return `🥇 ${ordinal} PLACE - TOP SCORE! 🥇`;
218
+ if (rank === 2) return `🥈 ${ordinal} PLACE 🥈`;
219
+ if (rank === 3) return `🥉 ${ordinal} PLACE 🥉`;
220
+ return `${ordinal} place on the leaderboard!`;
221
+ }
222
+
223
+ /**
224
+ * Get ordinal suffix for rank (1st, 2nd, 3rd, etc.)
225
+ */
226
+ getOrdinal(n) {
227
+ const s = ['th', 'st', 'nd', 'rd'];
228
+ const v = n % 100;
229
+ return n + (s[(v - 20) % 10] || s[v] || s[0]);
230
+ }
231
+
232
+ /**
233
+ * Setup event listeners for initials entry
234
+ */
235
+ setupInitialsEventListeners() {
236
+ // Arrow buttons
237
+ this.initialsModal.querySelectorAll('.arrow-up, .arrow-down').forEach(button => {
238
+ button.addEventListener('click', (e) => {
239
+ const slot = parseInt(e.target.dataset.slot);
240
+ const direction = e.target.dataset.direction;
241
+ this.changeInitialLetter(slot, direction === 'up' ? 1 : -1);
242
+ });
243
+ });
244
+
245
+ // Slot clicking to select
246
+ this.initialsModal.querySelectorAll('.initial-slot').forEach(slot => {
247
+ slot.addEventListener('click', (e) => {
248
+ if (!e.target.closest('.arrow-up') && !e.target.closest('.arrow-down')) {
249
+ const slotIndex = parseInt(slot.dataset.slot);
250
+ this.selectSlot(slotIndex);
251
+ }
252
+ });
253
+ });
254
+
255
+ // Submit button
256
+ this.initialsModal.querySelector('.initials-submit').addEventListener('click', () => {
257
+ this.submitInitials();
258
+ });
259
+
260
+ // Keyboard controls
261
+ this.initialsKeyHandler = (e) => {
262
+ switch(e.key) {
263
+ case 'ArrowUp':
264
+ e.preventDefault();
265
+ this.changeInitialLetter(this.currentSlot, 1);
266
+ break;
267
+ case 'ArrowDown':
268
+ e.preventDefault();
269
+ this.changeInitialLetter(this.currentSlot, -1);
270
+ break;
271
+ case 'ArrowLeft':
272
+ e.preventDefault();
273
+ this.selectSlot(Math.max(0, this.currentSlot - 1));
274
+ break;
275
+ case 'ArrowRight':
276
+ case 'Tab':
277
+ e.preventDefault();
278
+ this.selectSlot(Math.min(2, this.currentSlot + 1));
279
+ break;
280
+ case 'Enter':
281
+ e.preventDefault();
282
+ this.submitInitials();
283
+ break;
284
+ }
285
+ };
286
+ document.addEventListener('keydown', this.initialsKeyHandler);
287
+
288
+ // Prevent modal close on backdrop click for initials entry
289
+ this.initialsModal.addEventListener('click', (e) => {
290
+ e.stopPropagation();
291
+ });
292
+ }
293
+
294
+ /**
295
+ * Change letter in current slot
296
+ */
297
+ changeInitialLetter(slot, delta) {
298
+ const currentChar = this.initials[slot].charCodeAt(0);
299
+ let newChar = currentChar + delta;
300
+
301
+ // Wrap around A-Z
302
+ if (newChar > 90) newChar = 65; // After Z, go to A
303
+ if (newChar < 65) newChar = 90; // Before A, go to Z
304
+
305
+ this.initials[slot] = String.fromCharCode(newChar);
306
+ this.updateInitialsDisplay();
307
+ }
308
+
309
+ /**
310
+ * Select a specific slot
311
+ */
312
+ selectSlot(slot) {
313
+ this.currentSlot = slot;
314
+ this.initialsModal.querySelectorAll('.initial-slot').forEach((el, index) => {
315
+ el.classList.toggle('active', index === slot);
316
+ });
317
+ }
318
+
319
+ /**
320
+ * Update the visual display of initials
321
+ */
322
+ updateInitialsDisplay() {
323
+ this.initialsModal.querySelectorAll('.initial-slot').forEach((slot, index) => {
324
+ slot.querySelector('.slot-letter').textContent = this.initials[index];
325
+ });
326
+ }
327
+
328
+ /**
329
+ * Submit initials and save to leaderboard
330
+ */
331
+ submitInitials() {
332
+ const initialsString = this.initials.join('');
333
+
334
+ // Save to player profile
335
+ const profile = this.service.getPlayerProfile();
336
+ profile.initials = initialsString;
337
+ profile.hasEnteredInitials = true;
338
+ this.service.savePlayerProfile(profile);
339
+
340
+ // Call the callback
341
+ if (this.onInitialsSubmit) {
342
+ this.onInitialsSubmit(initialsString);
343
+ }
344
+
345
+ // Hide modal
346
+ this.hideInitialsEntry();
347
+
348
+ // Show success message briefly, then show leaderboard
349
+ this.showSuccessMessage(() => {
350
+ this.show();
351
+ });
352
+ }
353
+
354
+ /**
355
+ * Hide initials entry modal
356
+ */
357
+ hideInitialsEntry() {
358
+ if (this.initialsModal) {
359
+ this.initialsModal.classList.remove('visible');
360
+ setTimeout(() => {
361
+ if (this.initialsModal && this.initialsModal.parentNode) {
362
+ this.initialsModal.parentNode.removeChild(this.initialsModal);
363
+ }
364
+ this.initialsModal = null;
365
+ }, 300);
366
+ }
367
+
368
+ if (this.initialsKeyHandler) {
369
+ document.removeEventListener('keydown', this.initialsKeyHandler);
370
+ this.initialsKeyHandler = null;
371
+ }
372
+ }
373
+
374
+ /**
375
+ * Show success message after submitting initials
376
+ */
377
+ showSuccessMessage(onComplete) {
378
+ const successDiv = document.createElement('div');
379
+ successDiv.className = 'leaderboard-overlay visible';
380
+ successDiv.innerHTML = `
381
+ <div class="leaderboard-modal success-message">
382
+ <div class="success-content">
383
+ <h2>✓ SCORE SAVED!</h2>
384
+ <p>Your initials have been added to the leaderboard</p>
385
+ </div>
386
+ </div>
387
+ `;
388
+
389
+ document.body.appendChild(successDiv);
390
+
391
+ setTimeout(() => {
392
+ successDiv.classList.remove('visible');
393
+ setTimeout(() => {
394
+ if (successDiv.parentNode) {
395
+ successDiv.parentNode.removeChild(successDiv);
396
+ }
397
+ if (onComplete) {
398
+ onComplete();
399
+ }
400
+ }, 300);
401
+ }, 1500);
402
+ }
403
+
404
+ /**
405
+ * Show notification toast for milestone achievement
406
+ */
407
+ showMilestoneNotification(level) {
408
+ const toast = document.createElement('div');
409
+ toast.className = 'milestone-toast';
410
+ toast.innerHTML = `
411
+ <div class="toast-content">
412
+ 🎯 Milestone Reached: Level ${level}!
413
+ </div>
414
+ `;
415
+
416
+ document.body.appendChild(toast);
417
+
418
+ // Animate in
419
+ requestAnimationFrame(() => {
420
+ toast.classList.add('visible');
421
+ });
422
+
423
+ // Auto-hide after 3 seconds
424
+ setTimeout(() => {
425
+ toast.classList.remove('visible');
426
+ setTimeout(() => {
427
+ if (toast.parentNode) {
428
+ toast.parentNode.removeChild(toast);
429
+ }
430
+ }, 300);
431
+ }, 3000);
432
+ }
433
+ }
src/styles.css CHANGED
@@ -575,28 +575,512 @@
575
  }
576
  }
577
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
578
  /* Print styles */
579
  @media print {
580
  body {
581
  background: white;
582
  color: black;
583
  }
584
-
585
  .paper-sheet::before {
586
  display: none;
587
  }
588
-
589
  .typewriter-button {
590
  display: none;
591
  }
592
-
593
  .sticky-controls {
594
  display: none;
595
  }
596
-
597
  #game-container {
598
  padding-bottom: 0;
599
  }
 
 
 
 
 
600
  }
601
  }
602
 
 
575
  }
576
  }
577
 
578
+ /* Leaderboard Button Styling */
579
+ .typewriter-button-small {
580
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica Neue', Arial, sans-serif;
581
+ padding: 8px 16px;
582
+ background-color: var(--aged-paper-dark);
583
+ color: var(--typewriter-ink);
584
+ border: 2px solid black;
585
+ border-radius: 6px;
586
+ font-weight: 600;
587
+ font-size: 14px;
588
+ cursor: pointer;
589
+ transition: all 0.15s ease;
590
+ white-space: nowrap;
591
+ box-shadow:
592
+ 0 3px 0 rgba(0, 0, 0, 0.3),
593
+ 0 4px 8px rgba(0, 0, 0, 0.1);
594
+ }
595
+
596
+ .typewriter-button-small:hover {
597
+ background-color: rgba(0, 0, 0, 0.05);
598
+ transform: translateY(-1px);
599
+ box-shadow:
600
+ 0 4px 0 rgba(0, 0, 0, 0.3),
601
+ 0 6px 12px rgba(0, 0, 0, 0.15);
602
+ }
603
+
604
+ .typewriter-button-small:active {
605
+ transform: translateY(2px);
606
+ box-shadow:
607
+ 0 1px 0 rgba(0, 0, 0, 0.3),
608
+ 0 2px 4px rgba(0, 0, 0, 0.1);
609
+ }
610
+
611
+ /* Leaderboard Overlay */
612
+ .leaderboard-overlay {
613
+ position: fixed;
614
+ top: 0;
615
+ left: 0;
616
+ width: 100%;
617
+ height: 100%;
618
+ background: rgba(0, 0, 0, 0.75);
619
+ display: flex;
620
+ align-items: center;
621
+ justify-content: center;
622
+ z-index: 2000;
623
+ opacity: 0;
624
+ transition: opacity 0.3s ease;
625
+ backdrop-filter: blur(3px);
626
+ }
627
+
628
+ .leaderboard-overlay.visible {
629
+ opacity: 1;
630
+ }
631
+
632
+ /* Leaderboard Modal */
633
+ .leaderboard-modal {
634
+ background: var(--aged-paper-light);
635
+ border: 3px solid rgba(0, 0, 0, 0.4);
636
+ border-radius: 8px;
637
+ padding: 0;
638
+ box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
639
+ max-width: 600px;
640
+ width: 90%;
641
+ max-height: 85vh;
642
+ overflow: hidden;
643
+ animation: slideIn 0.3s ease-out;
644
+ }
645
+
646
+ @keyframes slideIn {
647
+ from {
648
+ transform: translateY(-30px);
649
+ opacity: 0;
650
+ }
651
+ to {
652
+ transform: translateY(0);
653
+ opacity: 1;
654
+ }
655
+ }
656
+
657
+ /* Leaderboard Header */
658
+ .leaderboard-header {
659
+ background: linear-gradient(180deg, var(--aged-paper-dark) 0%, #d4c9b3 100%);
660
+ padding: 20px 24px;
661
+ border-bottom: 3px solid rgba(0, 0, 0, 0.3);
662
+ display: flex;
663
+ justify-content: space-between;
664
+ align-items: center;
665
+ }
666
+
667
+ .leaderboard-title {
668
+ font-family: 'Special Elite', 'Courier New', monospace;
669
+ font-size: 24px;
670
+ font-weight: 700;
671
+ color: var(--typewriter-ink);
672
+ margin: 0;
673
+ letter-spacing: 1px;
674
+ }
675
+
676
+ .leaderboard-close {
677
+ background: transparent;
678
+ color: var(--typewriter-ink);
679
+ border: 2px solid rgba(0, 0, 0, 0.3);
680
+ border-radius: 4px;
681
+ width: 32px;
682
+ height: 32px;
683
+ font-size: 24px;
684
+ line-height: 1;
685
+ cursor: pointer;
686
+ transition: all 0.2s ease;
687
+ display: flex;
688
+ align-items: center;
689
+ justify-content: center;
690
+ }
691
+
692
+ .leaderboard-close:hover {
693
+ background: rgba(0, 0, 0, 0.1);
694
+ border-color: rgba(0, 0, 0, 0.5);
695
+ }
696
+
697
+ .leaderboard-close:active {
698
+ background: rgba(0, 0, 0, 0.15);
699
+ }
700
+
701
+ /* Leaderboard Content */
702
+ .leaderboard-content {
703
+ padding: 24px;
704
+ max-height: calc(85vh - 100px);
705
+ overflow-y: auto;
706
+ background: var(--aged-paper-light);
707
+ }
708
+
709
+ /* Leaderboard List */
710
+ .leaderboard-list {
711
+ display: flex;
712
+ flex-direction: column;
713
+ gap: 8px;
714
+ margin-bottom: 20px;
715
+ }
716
+
717
+ .leaderboard-entry {
718
+ display: grid;
719
+ grid-template-columns: 50px 80px 1fr;
720
+ align-items: center;
721
+ gap: 12px;
722
+ padding: 14px 16px;
723
+ background: var(--aged-paper-dark);
724
+ border: 2px solid rgba(0, 0, 0, 0.2);
725
+ border-radius: 6px;
726
+ font-family: 'Courier New', monospace;
727
+ transition: all 0.2s ease;
728
+ }
729
+
730
+ /* Rank Colors */
731
+ .leaderboard-entry.rank-gold {
732
+ background: linear-gradient(135deg, #fff7e6 0%, #ffeaa7 100%);
733
+ border-color: #d4af37;
734
+ border-width: 3px;
735
+ box-shadow: 0 2px 8px rgba(212, 175, 55, 0.3);
736
+ }
737
+
738
+ .leaderboard-entry.rank-silver {
739
+ background: linear-gradient(135deg, #fff9f2 0%, #f5deb3 100%);
740
+ border-color: #c9a869;
741
+ border-width: 2px;
742
+ }
743
+
744
+ .leaderboard-entry.rank-standard {
745
+ background: var(--aged-paper-dark);
746
+ }
747
+
748
+ .leaderboard-entry.player-entry {
749
+ background: linear-gradient(135deg, #f0e6ff 0%, #e0d1f5 100%);
750
+ border-color: #8b7aa8;
751
+ border-width: 3px;
752
+ }
753
+
754
+ .leaderboard-entry:hover {
755
+ transform: translateX(4px);
756
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
757
+ }
758
+
759
+ .entry-rank {
760
+ font-size: 20px;
761
+ font-weight: 700;
762
+ color: var(--typewriter-ink);
763
+ text-align: center;
764
+ }
765
+
766
+ .entry-initials {
767
+ font-size: 18px;
768
+ font-weight: 700;
769
+ color: var(--typewriter-ink);
770
+ letter-spacing: 2px;
771
+ text-align: center;
772
+ font-family: 'Courier New', monospace;
773
+ }
774
+
775
+ .entry-score {
776
+ font-size: 16px;
777
+ font-weight: 600;
778
+ color: #666;
779
+ }
780
+
781
+ .entry-round {
782
+ font-size: 14px;
783
+ color: #999;
784
+ font-weight: 400;
785
+ }
786
+
787
+ /* Empty State */
788
+ .leaderboard-empty {
789
+ text-align: center;
790
+ padding: 40px 20px;
791
+ color: #666;
792
+ }
793
+
794
+ .leaderboard-empty p {
795
+ margin: 8px 0;
796
+ font-size: 16px;
797
+ }
798
+
799
+ /* Player Stats */
800
+ .leaderboard-player-stats {
801
+ margin-top: 24px;
802
+ padding: 18px;
803
+ background: rgba(139, 115, 170, 0.08);
804
+ border: 2px solid rgba(139, 115, 170, 0.25);
805
+ border-radius: 6px;
806
+ }
807
+
808
+ .player-best {
809
+ font-size: 16px;
810
+ font-weight: 600;
811
+ color: var(--typewriter-ink);
812
+ margin-bottom: 8px;
813
+ }
814
+
815
+ .player-best .highlight {
816
+ color: #6b4a8e;
817
+ font-weight: 700;
818
+ }
819
+
820
+ .player-stats-details {
821
+ display: flex;
822
+ flex-direction: column;
823
+ gap: 4px;
824
+ font-size: 14px;
825
+ color: #666;
826
+ }
827
+
828
+ /* Initials Entry Modal */
829
+ .initials-overlay {
830
+ background: rgba(0, 0, 0, 0.9);
831
+ }
832
+
833
+ .initials-modal {
834
+ max-width: 500px;
835
+ }
836
+
837
+ .initials-header {
838
+ background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%);
839
+ padding: 24px;
840
+ border-bottom: 3px solid black;
841
+ text-align: center;
842
+ }
843
+
844
+ .initials-title {
845
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica Neue', Arial, sans-serif;
846
+ font-size: 24px;
847
+ font-weight: 700;
848
+ color: white;
849
+ margin: 0 0 12px 0;
850
+ text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3);
851
+ }
852
+
853
+ .initials-achievement {
854
+ font-size: 16px;
855
+ color: rgba(255, 255, 255, 0.95);
856
+ line-height: 1.6;
857
+ }
858
+
859
+ .initials-achievement .highlight {
860
+ color: #fde68a;
861
+ font-weight: 700;
862
+ }
863
+
864
+ .rank-text {
865
+ font-size: 14px;
866
+ color: #fde68a;
867
+ font-weight: 600;
868
+ }
869
+
870
+ .initials-content {
871
+ padding: 32px 24px;
872
+ text-align: center;
873
+ }
874
+
875
+ .initials-prompt {
876
+ font-size: 18px;
877
+ font-weight: 600;
878
+ color: var(--typewriter-ink);
879
+ margin-bottom: 24px;
880
+ }
881
+
882
+ /* Initials Slots */
883
+ .initials-slots {
884
+ display: flex;
885
+ justify-content: center;
886
+ gap: 16px;
887
+ margin-bottom: 24px;
888
+ }
889
+
890
+ .initial-slot {
891
+ display: flex;
892
+ flex-direction: column;
893
+ align-items: center;
894
+ gap: 8px;
895
+ }
896
+
897
+ .initial-slot.active .slot-letter {
898
+ border-color: #8b5cf6;
899
+ background: rgba(139, 92, 246, 0.1);
900
+ box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.3);
901
+ }
902
+
903
+ .slot-letter {
904
+ width: 64px;
905
+ height: 80px;
906
+ display: flex;
907
+ align-items: center;
908
+ justify-content: center;
909
+ font-size: 48px;
910
+ font-weight: 700;
911
+ font-family: 'Courier New', monospace;
912
+ color: var(--typewriter-ink);
913
+ background: var(--aged-paper-dark);
914
+ border: 3px solid black;
915
+ border-radius: 8px;
916
+ transition: all 0.2s ease;
917
+ box-shadow: 0 4px 0 rgba(0, 0, 0, 0.3);
918
+ }
919
+
920
+ .slot-arrows {
921
+ display: flex;
922
+ flex-direction: column;
923
+ gap: 4px;
924
+ }
925
+
926
+ .arrow-up, .arrow-down {
927
+ width: 40px;
928
+ height: 32px;
929
+ background: var(--aged-paper-dark);
930
+ border: 2px solid black;
931
+ border-radius: 4px;
932
+ font-size: 16px;
933
+ cursor: pointer;
934
+ transition: all 0.15s ease;
935
+ display: flex;
936
+ align-items: center;
937
+ justify-content: center;
938
+ }
939
+
940
+ .arrow-up:hover, .arrow-down:hover {
941
+ background: rgba(0, 0, 0, 0.05);
942
+ transform: scale(1.1);
943
+ }
944
+
945
+ .arrow-up:active, .arrow-down:active {
946
+ transform: scale(0.95);
947
+ }
948
+
949
+ /* Initials Instructions */
950
+ .initials-instructions {
951
+ margin-bottom: 24px;
952
+ color: #666;
953
+ font-size: 13px;
954
+ line-height: 1.6;
955
+ }
956
+
957
+ .initials-instructions p {
958
+ margin: 4px 0;
959
+ }
960
+
961
+ .initials-submit {
962
+ width: 100%;
963
+ max-width: 200px;
964
+ min-height: 48px;
965
+ font-size: 16px;
966
+ }
967
+
968
+ /* Success Message */
969
+ .success-message {
970
+ max-width: 400px;
971
+ padding: 32px;
972
+ text-align: center;
973
+ }
974
+
975
+ .success-content h2 {
976
+ font-size: 32px;
977
+ color: #10b981;
978
+ margin-bottom: 12px;
979
+ }
980
+
981
+ .success-content p {
982
+ font-size: 16px;
983
+ color: #666;
984
+ }
985
+
986
+ /* Milestone Toast */
987
+ .milestone-toast {
988
+ position: fixed;
989
+ top: 20px;
990
+ left: 50%;
991
+ transform: translateX(-50%) translateY(-100px);
992
+ background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%);
993
+ color: white;
994
+ padding: 16px 24px;
995
+ border-radius: 12px;
996
+ border: 3px solid black;
997
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
998
+ z-index: 3000;
999
+ transition: transform 0.3s ease;
1000
+ font-weight: 600;
1001
+ font-size: 18px;
1002
+ }
1003
+
1004
+ .milestone-toast.visible {
1005
+ transform: translateX(-50%) translateY(0);
1006
+ }
1007
+
1008
+ /* Mobile Responsive */
1009
+ @media (max-width: 640px) {
1010
+ .leaderboard-modal {
1011
+ width: 95%;
1012
+ max-height: 90vh;
1013
+ }
1014
+
1015
+ .leaderboard-title {
1016
+ font-size: 20px;
1017
+ }
1018
+
1019
+ .leaderboard-entry {
1020
+ grid-template-columns: 40px 70px 1fr;
1021
+ gap: 8px;
1022
+ padding: 12px;
1023
+ }
1024
+
1025
+ .entry-rank {
1026
+ font-size: 16px;
1027
+ }
1028
+
1029
+ .entry-initials {
1030
+ font-size: 16px;
1031
+ }
1032
+
1033
+ .entry-score {
1034
+ font-size: 14px;
1035
+ }
1036
+
1037
+ .initials-modal {
1038
+ width: 95%;
1039
+ }
1040
+
1041
+ .slot-letter {
1042
+ width: 56px;
1043
+ height: 70px;
1044
+ font-size: 40px;
1045
+ }
1046
+
1047
+ .initials-slots {
1048
+ gap: 12px;
1049
+ }
1050
+
1051
+ .typewriter-button-small {
1052
+ font-size: 13px;
1053
+ padding: 8px 18px;
1054
+ }
1055
+ }
1056
+
1057
  /* Print styles */
1058
  @media print {
1059
  body {
1060
  background: white;
1061
  color: black;
1062
  }
1063
+
1064
  .paper-sheet::before {
1065
  display: none;
1066
  }
1067
+
1068
  .typewriter-button {
1069
  display: none;
1070
  }
1071
+
1072
  .sticky-controls {
1073
  display: none;
1074
  }
1075
+
1076
  #game-container {
1077
  padding-bottom: 0;
1078
  }
1079
+
1080
+ .leaderboard-overlay,
1081
+ .milestone-toast {
1082
+ display: none;
1083
+ }
1084
  }
1085
  }
1086
 
src/welcomeOverlay.js CHANGED
@@ -43,15 +43,15 @@ class WelcomeOverlay {
43
 
44
  <div class="welcome-content">
45
  <p>
46
- <strong>How to play:</strong> Fill in the blanks in each passage. Complete 2 passages per round. Pass 2 rounds to advance to the next level.
47
  </p>
48
-
49
  <p>
50
- <strong>Data source:</strong> Randomly excerpted historical and literary texts from Project Gutenberg's public domain collection, processed via Hugging Face Datasets.
51
  </p>
52
-
53
  <p style="margin-bottom: 0;">
54
- <strong>AI assistance:</strong> Powered by Google's Gemma models via OpenRouter - Gemma-3-27b for hints and Gemma-3-12b for word selection and processing.
55
  </p>
56
  </div>
57
 
 
43
 
44
  <div class="welcome-content">
45
  <p>
46
+ <strong>How to play:</strong> Fill in the blanks to advance through levels with increasing difficulty and vocabulary complexity.
47
  </p>
48
+
49
  <p>
50
+ <strong>Data source:</strong> Excerpted historical and literary texts from Project Gutenberg's public domain collection, processed via Hugging Face Datasets.
51
  </p>
52
+
53
  <p style="margin-bottom: 0;">
54
+ <strong>AI assistance:</strong> Powered by Google's Gemma-3-27b model via OpenRouter for word selection, hints, and contextualization.
55
  </p>
56
  </div>
57