diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index b81c3e0..f9fc874 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -106,15 +106,33 @@ Valid channels (extend as needed): - When a game is chosen, requests its HTML fragment and injects it into a `
` element. - Calls `plugin.init()` then `plugin.start()` on the loaded game module. -### 4 — Game Plugin Registry +### 4 — Game Structure -The registry (`app/games/registry.js`) is loaded by the main process at startup: +Games are all stored in `app/games//`. +They must all have a manifest (`manifest.json`) that includes at least: + +```json +{ + "id": "game-id", + "name": "Game Name", + "description": "Game description goes here.", + "version": "0.1.0", + "entryPoint": "index.js", + "thumbnail": "images/thumbnail.png", + "author": "Author Name" +}; ``` -startup - └─ scanGamesDirectory() // reads app/games/*/manifest.json - └─ returns GameManifest[] // passed to renderer on request -``` + +Games must all have a welcome screen that explains how to play, and a consistent UI for showing the current score and round. +The core game logic must be in `game.js` as pure functions, or helper libraries, that can be easily unit tested. +The `index.js` file should export the plugin API (`init`, `start`, `stop`, `reset`) that the renderer calls. + +When the player clicks "Stop" or finishes the game, the plugin must return a result object that includes at least a `score` property. +The renderer will take care of saving progress via IPC. When the player subsequently leaves the game, they must be returned to the main welcome screen with the list of games. +All game cards should have been updated with any updated scores. + +#### Plugin Registry When the renderer asks to load a game by ID, the main process: @@ -152,7 +170,7 @@ When the renderer asks to load a game by ID, the main process: All files and functions must include JSDoc comments. Use descriptive names for variables and functions. Use US English spelling (e.g. "initialize" not "initialise"). -When files get too large, break them into smaller modules. For example, if `index.js` exceeds 500 lines, consider moving game logic to `game.js` and UI rendering to `render.js`. +When files get too large, break them into smaller modules. For example, if `index.js` exceeds 500 lines, consider moving game logic to `game.js` and UI rendering to `render.js`. Any file over 1000 lines is a red flag. ### Linting diff --git a/app/games/high-speed-memory/game.js b/app/games/high-speed-memory/game.js new file mode 100644 index 0000000..26d1f06 --- /dev/null +++ b/app/games/high-speed-memory/game.js @@ -0,0 +1,246 @@ +/** + * game.js — Pure game logic for High Speed Memory. + * + * Contains all state and logic for the High Speed Memory game, with no DOM access. + * All functions are easily unit-testable. + * + * @file High Speed Memory game logic module. + */ + +/** + * Filename of the target card that the player must find. + * Appears exactly PRIMARY_COUNT times in every grid. + */ +export const PRIMARY_IMAGE = 'Primary.jpg'; + +/** + * Filenames of distractor card images. + * These fill all grid cells that are not the Primary card. + */ +export const DISTRACTOR_IMAGES = [ + 'Distractor1.jpg', + 'Distractor2.jpg', + 'Distractor3.jpg', +]; + +/** + * Number of Primary card copies placed in each round's grid. + * The player wins the round by finding all of them. + */ +export const PRIMARY_COUNT = 3; + +/** + * Number of consecutive correct rounds (no wrong guesses) required to advance one level. + * A wrong guess in any round resets this streak back to zero. + */ +export const ROUNDS_TO_LEVEL_UP = 3; + +/** Initial card-reveal display duration in milliseconds (level 0). */ +export const BASE_DISPLAY_MS = 1500; + +/** Amount to reduce display duration per level (ms). */ +export const DISPLAY_DECREMENT_MS = 25; + +/** Minimum display duration regardless of level (ms). */ +export const MIN_DISPLAY_MS = 20; + +/** @type {number} */ +let score = 0; + +/** @type {number} */ +let level = 0; + +/** @type {number} */ +let roundsCompleted = 0; + +/** + * Number of consecutive correct rounds completed without a wrong guess. + * Resets to 0 after a wrong guess or after reaching ROUNDS_TO_LEVEL_UP. + * @type {number} + */ +let consecutiveCorrectRounds = 0; + +/** @type {boolean} */ +let running = false; + +/** @type {number|null} */ +let startTime = null; + +/** + * Initialize (or reset) all game state. + */ +export function initGame() { + score = 0; + level = 0; + roundsCompleted = 0; + consecutiveCorrectRounds = 0; + running = false; + startTime = null; +} + +/** + * Start the game timer. + * @throws {Error} If the game is already running. + */ +export function startGame() { + if (running) { + throw new Error('Game is already running.'); + } + running = true; + startTime = Date.now(); +} + +/** + * Stop the game and return final results. + * @returns {{ score: number, level: number, roundsCompleted: number, duration: number }} + * @throws {Error} If the game is not running. + */ +export function stopGame() { + if (!running) { + throw new Error('Game is not running.'); + } + running = false; + const duration = startTime !== null ? Date.now() - startTime : 0; + return { + score, + level, + roundsCompleted, + duration, + }; +} + +/** + * Get the square grid dimensions for a given level. + * Grids start at 3×3 and grow by 1 each level with no upper bound. + * + * @param {number} lvl - The game level (0-based). + * @returns {{ rows: number, cols: number }} + */ +export function getGridSize(lvl) { + const n = lvl + 3; + return { rows: n, cols: n }; +} + +/** + * Get the card-reveal display duration in milliseconds for a given level. + * Ranges from BASE_DISPLAY_MS down to MIN_DISPLAY_MS. + * + * @param {number} lvl - The game level (0-based). + * @returns {number} Display duration in milliseconds. + */ +export function getDisplayDurationMs(lvl) { + return Math.max(BASE_DISPLAY_MS - lvl * DISPLAY_DECREMENT_MS, MIN_DISPLAY_MS); +} + +/** + * Generate a shuffled array of card objects for a given level. + * Each card has { id, image, matched }. + * Exactly PRIMARY_COUNT cards show PRIMARY_IMAGE; the rest are random DISTRACTOR_IMAGES. + * The grid is fully filled (rows × cols cards, no empty cells). + * + * @param {number} lvl - The game level (0-based). + * @returns {Array<{ id: number, image: string, matched: boolean }>} + */ +export function generateGrid(lvl) { + const { rows, cols } = getGridSize(lvl); + const totalCards = rows * cols; + + // Build the array: PRIMARY_COUNT copies of the primary image, rest are random distractors + const cardImages = []; + for (let i = 0; i < PRIMARY_COUNT; i += 1) { + cardImages.push(PRIMARY_IMAGE); + } + for (let i = PRIMARY_COUNT; i < totalCards; i += 1) { + const idx = Math.floor(Math.random() * DISTRACTOR_IMAGES.length); + cardImages.push(DISTRACTOR_IMAGES[idx]); + } + + // Fisher-Yates shuffle + for (let i = cardImages.length - 1; i > 0; i -= 1) { + const j = Math.floor(Math.random() * (i + 1)); + [cardImages[i], cardImages[j]] = [cardImages[j], cardImages[i]]; + } + + // Assign sequential ids matching array position + return cardImages.map((image, i) => ({ id: i, image, matched: false })); +} + +/** + * Check whether a card image is the Primary target image. + * + * @param {string} image - The image filename to check. + * @returns {boolean} True if the image is the Primary target. + */ +export function isPrimary(image) { + return image === PRIMARY_IMAGE; +} + +/** + * Record a correctly found Primary card and increment the score. + */ +export function addCorrectGroup() { + score += 1; +} + +/** + * Mark the current round as complete. + * Increments the consecutive-correct-rounds streak. + * The level only advances when ROUNDS_TO_LEVEL_UP consecutive correct rounds are reached, + * at which point the streak resets to zero. + */ +export function completeRound() { + roundsCompleted += 1; + consecutiveCorrectRounds += 1; + if (consecutiveCorrectRounds >= ROUNDS_TO_LEVEL_UP) { + level += 1; + consecutiveCorrectRounds = 0; + } +} + +/** + * Reset the consecutive-correct-rounds streak to zero. + * Called when the player clicks a Distractor card (wrong guess). + */ +export function resetConsecutiveRounds() { + consecutiveCorrectRounds = 0; +} + +/** + * Get the current score. + * @returns {number} + */ +export function getScore() { + return score; +} + +/** + * Get the current level (0-based). + * @returns {number} + */ +export function getLevel() { + return level; +} + +/** + * Get the number of rounds completed. + * @returns {number} + */ +export function getRoundsCompleted() { + return roundsCompleted; +} + +/** + * Get the current consecutive-correct-rounds streak. + * @returns {number} + */ +export function getConsecutiveCorrectRounds() { + return consecutiveCorrectRounds; +} + +/** + * Check whether the game is currently running. + * @returns {boolean} + */ +export function isRunning() { + return running; +} diff --git a/app/games/high-speed-memory/images/Distractor1.jpg b/app/games/high-speed-memory/images/Distractor1.jpg new file mode 100644 index 0000000..e7dc47d Binary files /dev/null and b/app/games/high-speed-memory/images/Distractor1.jpg differ diff --git a/app/games/high-speed-memory/images/Distractor2.jpg b/app/games/high-speed-memory/images/Distractor2.jpg new file mode 100644 index 0000000..d6cef4e Binary files /dev/null and b/app/games/high-speed-memory/images/Distractor2.jpg differ diff --git a/app/games/high-speed-memory/images/Distractor3.jpg b/app/games/high-speed-memory/images/Distractor3.jpg new file mode 100644 index 0000000..7f2d268 Binary files /dev/null and b/app/games/high-speed-memory/images/Distractor3.jpg differ diff --git a/app/games/high-speed-memory/images/Primary.jpg b/app/games/high-speed-memory/images/Primary.jpg new file mode 100644 index 0000000..56b9d02 Binary files /dev/null and b/app/games/high-speed-memory/images/Primary.jpg differ diff --git a/app/games/high-speed-memory/images/thumbnail.jpg b/app/games/high-speed-memory/images/thumbnail.jpg new file mode 100644 index 0000000..67f10ef Binary files /dev/null and b/app/games/high-speed-memory/images/thumbnail.jpg differ diff --git a/app/games/high-speed-memory/index.js b/app/games/high-speed-memory/index.js new file mode 100644 index 0000000..461ec2f --- /dev/null +++ b/app/games/high-speed-memory/index.js @@ -0,0 +1,539 @@ +/** + * index.js — High Speed Memory game plugin entry point for BrainSpeedExercises. + * + * Handles all DOM, rendering, and event logic for the High Speed Memory game UI. + * Exports the plugin contract for dynamic loading by the app shell. + * + * @file High Speed Memory game plugin (UI/controller layer). + */ + +import * as game from './game.js'; + +/** + * Delay in ms before a wrongly-clicked Distractor card flips back face-down. + */ +const WRONG_FLIP_DELAY_MS = 900; + +/** + * Base path for card images relative to the renderer's root (app/index.html). + * Images are stored alongside this game's own files. + */ +const IMAGES_PATH = 'games/high-speed-memory/images/'; + +// ── DOM references (populated by init) ──────────────────────────────────────── + +/** @type {HTMLElement|null} */ +let _container = null; + +/** @type {HTMLElement|null} */ +let _instructionsEl = null; + +/** @type {HTMLElement|null} */ +let _gameAreaEl = null; + +/** @type {HTMLElement|null} */ +let _endPanelEl = null; + +/** @type {HTMLElement|null} */ +let _startBtn = null; + +/** @type {HTMLElement|null} */ +let _stopBtn = null; + +/** @type {HTMLElement|null} */ +let _playAgainBtn = null; + +/** @type {HTMLElement|null} */ +let _returnToMenuBtn = null; + +/** @type {HTMLElement|null} */ +let _gridEl = null; + +/** @type {HTMLElement|null} */ +let _scoreEl = null; + +/** @type {HTMLElement|null} */ +let _levelEl = null; + +/** @type {HTMLElement|null} */ +let _foundEl = null; + +/** @type {HTMLElement|null} */ +let _feedbackEl = null; + +/** @type {HTMLElement|null} */ +let _finalScoreEl = null; + +/** @type {HTMLElement|null} */ +let _finalLevelEl = null; + +/** @type {HTMLElement|null} */ +let _streakEl = null; + +// ── Round state (reset each round) ──────────────────────────────────────────── + +/** + * Current round's card data (from game.generateGrid). + * @type {Array<{ id: number, image: string, matched: boolean }>} + */ +let _roundGrid = []; + +/** + * When true, card clicks are ignored (during reveal phase or wrong-card flip-back). + * @type {boolean} + */ +let _flipLock = false; + +/** + * Number of Primary cards correctly found in the current round. + * @type {number} + */ +let _primaryFound = 0; + +/** + * Pending setTimeout handle for restarting the round after a wrong guess. + * @type {ReturnType|null} + */ +let _roundRestartTimer = null; + +/** + * Pending setTimeout handle for hiding all cards after reveal phase. + * @type {ReturnType|null} + */ +let _hideTimer = null; + +// ── Audio ───────────────────────────────────────────────────────────────────── + +/** + * Play a short buzzer sound to indicate a wrong guess. + * Uses the Web Audio API; silently no-ops if the API is unavailable. + */ +export function playWrongSound() { + const AudioCtx = (typeof AudioContext !== 'undefined' && AudioContext) + || (typeof window !== 'undefined' && window.webkitAudioContext) + || null; + if (!AudioCtx) return; + try { + const ctx = new AudioCtx(); + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + osc.connect(gain); + gain.connect(ctx.destination); + osc.type = 'sawtooth'; + osc.frequency.setValueAtTime(180, ctx.currentTime); + gain.gain.setValueAtTime(0.25, ctx.currentTime); + gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.4); + osc.start(ctx.currentTime); + osc.stop(ctx.currentTime + 0.4); + osc.onended = () => { ctx.close().catch(() => {}); }; + } catch { + // Ignore any audio initialization errors + } +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +/** + * Announce a message to the screen-reader feedback region. + * @param {string} msg - Text to announce. + */ +export function announce(msg) { + if (_feedbackEl) { + _feedbackEl.textContent = msg; + } +} + +/** + * Update the score, level, and streak displays. + */ +export function updateStats() { + if (_scoreEl) _scoreEl.textContent = String(game.getScore()); + if (_levelEl) _levelEl.textContent = String(game.getLevel() + 1); + if (_streakEl) _streakEl.textContent = String(game.getConsecutiveCorrectRounds()); +} + +/** + * Update the "Found: x/3" counter display. + */ +export function updateFoundDisplay() { + if (_foundEl) _foundEl.textContent = String(_primaryFound); +} + +/** + * Build and inject the card grid DOM for the current round. + * Clears any existing grid content first. + * Cards are rendered face-up during the reveal phase. + */ +export function renderGrid() { + if (!_gridEl) return; + _gridEl.innerHTML = ''; + + const { rows, cols } = game.getGridSize(game.getLevel()); + + _gridEl.style.gridTemplateColumns = `repeat(${cols}, 1fr)`; + _gridEl.style.gridTemplateRows = `repeat(${rows}, 1fr)`; + + _roundGrid.forEach((card) => { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'hsm-card hsm-card--revealed'; + btn.setAttribute('aria-label', `Card ${card.id + 1}`); + btn.setAttribute('data-id', String(card.id)); + btn.setAttribute('data-image', card.image); + + const img = document.createElement('img'); + img.src = `${IMAGES_PATH}${card.image}`; + img.alt = ''; + img.setAttribute('aria-hidden', 'true'); + img.className = 'hsm-card__img'; + btn.appendChild(img); + + btn.addEventListener('click', () => handleCardClick(card.id)); + btn.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + handleCardClick(card.id); + } + }); + _gridEl.appendChild(btn); + }); +} + +/** + * Flip a single card face-down in the DOM (does not modify _roundGrid state). + * Hides the card image and removes the revealed styling. + * @param {number} cardId - The id of the card to hide. + */ +export function hideCardEl(cardId) { + const btn = _gridEl && _gridEl.querySelector(`[data-id="${cardId}"]`); + if (!btn) return; + btn.classList.remove('hsm-card--revealed', 'hsm-card--wrong'); + btn.setAttribute('aria-label', `Card ${cardId + 1}: face down`); + const img = btn.querySelector('img'); + if (img) img.style.display = 'none'; +} + +/** + * Flip a card face-up in the DOM. + * @param {number} cardId - The id of the card to reveal. + * @param {string} imageName - The image filename to display. + */ +export function revealCardEl(cardId, imageName) { + const btn = _gridEl && _gridEl.querySelector(`[data-id="${cardId}"]`); + if (!btn) return; + btn.classList.add('hsm-card--revealed'); + btn.classList.remove('hsm-card--wrong'); + btn.setAttribute('aria-label', `Card ${cardId + 1}: revealed`); + const img = btn.querySelector('img'); + if (img) { + img.src = `${IMAGES_PATH}${imageName}`; + img.style.display = ''; + } +} + +/** + * Apply the "matched" visual state to a card element (correctly found Primary card). + * @param {number} cardId - The id of the card to mark as matched. + */ +export function markCardMatched(cardId) { + const btn = _gridEl && _gridEl.querySelector(`[data-id="${cardId}"]`); + if (!btn) return; + btn.classList.add('hsm-card--matched'); + btn.classList.remove('hsm-card--revealed', 'hsm-card--wrong'); + btn.disabled = true; + btn.setAttribute('aria-label', `Card ${cardId + 1}: matched`); + const img = btn.querySelector('img'); + if (img) img.style.display = ''; +} + +/** + * Apply the "wrong guess" visual state to a card element. + * @param {number} cardId - The id of the card to mark as wrong. + */ +export function markCardWrong(cardId) { + const btn = _gridEl && _gridEl.querySelector(`[data-id="${cardId}"]`); + if (!btn) return; + btn.classList.add('hsm-card--wrong'); +} + +/** + * Hide all un-matched cards after the reveal phase ends. + * Called by the timer set in startRound. + */ +export function hideAllCards() { + _roundGrid.forEach((card) => { + if (!card.matched) { + hideCardEl(card.id); + } + }); + _flipLock = false; + announce(`Cards hidden — find the ${game.PRIMARY_COUNT} matching cards!`); +} + +/** + * Start a new round: generate a fresh grid, render it revealed, then hide after delay. + */ +export function startRound() { + _primaryFound = 0; + _flipLock = true; + + _roundGrid = game.generateGrid(game.getLevel()); + renderGrid(); + updateStats(); + updateFoundDisplay(); + + const displayMs = game.getDisplayDurationMs(game.getLevel()); + + announce( + `Level ${game.getLevel() + 1}. Find the ${game.PRIMARY_COUNT} matching cards.`, + ); + + _hideTimer = setTimeout(hideAllCards, displayMs); +} + +/** + * Handle a card click (or keyboard activation). + * Each click is evaluated immediately: + * - Primary card → mark found; advance level when all PRIMARY_COUNT are found. + * - Distractor card → play wrong-guess sound and flip back after WRONG_FLIP_DELAY_MS. + * Clicks are ignored during the reveal phase (flip lock) or on already-matched cards. + * + * @param {number} cardId - The id of the clicked card. + */ +export function handleCardClick(cardId) { + if (_flipLock) return; + + const card = _roundGrid.find((c) => c.id === cardId); + if (!card || card.matched) return; + + // Reveal the card so the player can see what they clicked + revealCardEl(cardId, card.image); + + if (game.isPrimary(card.image)) { + // Correct — mark this Primary card as found + card.matched = true; + markCardMatched(cardId); + game.addCorrectGroup(); + _primaryFound += 1; + updateStats(); + updateFoundDisplay(); + announce(`Found one! ${_primaryFound} of ${game.PRIMARY_COUNT} found.`); + + if (_primaryFound >= game.PRIMARY_COUNT) { + onRoundComplete(); + } + } else { + // Wrong — reset streak, play sound, then restart the round after a brief delay + game.resetConsecutiveRounds(); + markCardWrong(cardId); + playWrongSound(); + updateStats(); + announce('Wrong guess! The round will restart.'); + + _flipLock = true; + clearTimers(); + _roundRestartTimer = setTimeout(() => { + startRound(); + }, WRONG_FLIP_DELAY_MS); + } +} + +/** + * Called when all PRIMARY_COUNT cards in the current round have been found. + * Advances the level-up streak (and level if streak reaches ROUNDS_TO_LEVEL_UP), + * then starts the next round after a brief pause. + */ +function onRoundComplete() { + game.completeRound(); + updateStats(); + + // After completeRound: consecutiveCorrectRounds resets to 0 on level advance + const leveledUp = game.getConsecutiveCorrectRounds() === 0; + if (leveledUp) { + announce(`Level up! Welcome to level ${game.getLevel() + 1}.`); + } else { + const streak = game.getConsecutiveCorrectRounds(); + const needed = game.ROUNDS_TO_LEVEL_UP; + announce( + `Round complete! ${streak} of ${needed} in a row — ${needed - streak} more to level up!`, + ); + } + + // Brief pause so the player sees the completed board before the next round starts + setTimeout(startRound, 1200); +} + +/** + * Dispatch the app-level event to return to the main game-selection screen. + * Safe to call in non-browser (test) environments. + */ +function returnToMainMenu() { + if (typeof window !== 'undefined') { + window.dispatchEvent(new CustomEvent('bsx:return-to-main-menu')); + } +} + +/** + * Clear any pending timers (used during stop/reset). + */ +function clearTimers() { + if (_roundRestartTimer !== null) { + clearTimeout(_roundRestartTimer); + _roundRestartTimer = null; + } + if (_hideTimer !== null) { + clearTimeout(_hideTimer); + _hideTimer = null; + } +} + +/** + * Show the end-game panel with final results. + * @param {{ score: number, level: number }} result + */ +function showEndPanel(result) { + if (_gameAreaEl) _gameAreaEl.hidden = true; + if (_endPanelEl) _endPanelEl.hidden = false; + if (_finalScoreEl) _finalScoreEl.textContent = String(result.score); + if (_finalLevelEl) _finalLevelEl.textContent = String(result.level + 1); +} + +// ── Plugin contract ──────────────────────────────────────────────────────────── + +/** Human-readable name returned as part of the plugin contract. */ +const name = 'High Speed Memory'; + +/** + * Initialize the plugin. + * Called once after interface.html has been injected into the game container. + * Queries DOM elements and attaches event listeners; does not start timers. + * + * @param {HTMLElement} gameContainer - The container element holding the game HTML. + */ +function init(gameContainer) { + _container = gameContainer; + game.initGame(); + + if (!_container) return; + + _instructionsEl = _container.querySelector('#hsm-instructions'); + _gameAreaEl = _container.querySelector('#hsm-game-area'); + _endPanelEl = _container.querySelector('#hsm-end-panel'); + _startBtn = _container.querySelector('#hsm-start-btn'); + _stopBtn = _container.querySelector('#hsm-stop-btn'); + _playAgainBtn = _container.querySelector('#hsm-play-again-btn'); + _returnToMenuBtn = _container.querySelector('#hsm-return-btn'); + _gridEl = _container.querySelector('#hsm-grid'); + _scoreEl = _container.querySelector('#hsm-score'); + _levelEl = _container.querySelector('#hsm-level'); + _foundEl = _container.querySelector('#hsm-found'); + _feedbackEl = _container.querySelector('#hsm-feedback'); + _finalScoreEl = _container.querySelector('#hsm-final-score'); + _finalLevelEl = _container.querySelector('#hsm-final-level'); + _streakEl = _container.querySelector('#hsm-streak'); + + if (_startBtn) { + _startBtn.addEventListener('click', () => start()); + } + if (_stopBtn) { + _stopBtn.addEventListener('click', () => stop()); + } + if (_playAgainBtn) { + _playAgainBtn.addEventListener('click', () => { + reset(); + start(); + }); + } + if (_returnToMenuBtn) { + _returnToMenuBtn.addEventListener('click', () => returnToMainMenu()); + } +} + +/** + * Start the game. + * Hides the instructions panel, shows the game area, and begins the first round. + */ +function start() { + game.startGame(); + + if (_instructionsEl) _instructionsEl.hidden = true; + if (_endPanelEl) _endPanelEl.hidden = true; + if (_gameAreaEl) _gameAreaEl.hidden = false; + + startRound(); +} + +/** + * Stop the game, persist progress, and show the end-game panel. + * Progress is saved asynchronously (fire-and-forget); the game result is returned synchronously. + * + * @returns {{ score: number, level: number, roundsCompleted: number, duration: number }} + */ +function stop() { + clearTimers(); + const result = game.stopGame(); + + // Save progress asynchronously — fire and forget + (async () => { + if (typeof window !== 'undefined' && window.api) { + try { + let existing = { playerId: 'default', games: {} }; + try { + existing = await window.api.invoke('progress:load', { playerId: 'default' }) || existing; + } catch { + // If load fails, continue with defaults + } + const prev = (existing.games && existing.games['high-speed-memory']) || {}; + const updated = { + ...existing, + games: { + ...existing.games, + 'high-speed-memory': { + highScore: Math.max(result.score, prev.highScore || 0), + sessionsPlayed: (prev.sessionsPlayed || 0) + 1, + lastPlayed: new Date().toISOString(), + highestLevel: Math.max(result.level, prev.highestLevel || 0), + }, + }, + }; + await window.api.invoke('progress:save', { playerId: 'default', data: updated }); + } catch { + // Swallow all progress save/load errors + } + } + })(); + + showEndPanel(result); + return result; +} + +/** + * Reset the game to its initial state without reloading interface.html. + */ +function reset() { + clearTimers(); + game.initGame(); + + _roundGrid = []; + _flipLock = false; + _primaryFound = 0; + + if (_gridEl) _gridEl.innerHTML = ''; + if (_instructionsEl) _instructionsEl.hidden = false; + if (_gameAreaEl) _gameAreaEl.hidden = true; + if (_endPanelEl) _endPanelEl.hidden = true; + if (_feedbackEl) _feedbackEl.textContent = ''; + if (_streakEl) _streakEl.textContent = '0'; + + updateStats(); + updateFoundDisplay(); +} + +export default { + name, + init, + start, + stop, + reset, +}; + diff --git a/app/games/high-speed-memory/interface.html b/app/games/high-speed-memory/interface.html new file mode 100644 index 0000000..da56fe9 --- /dev/null +++ b/app/games/high-speed-memory/interface.html @@ -0,0 +1,90 @@ + + +
+ +

High Speed Memory

+ + +
+

How to Play

+

+ A grid of cards will flash open briefly — remember where the + Primary image appears! + After they flip back, click all three cards that showed the Primary image. +

+

+ Find this card — it appears three times each round: +

+ The Primary card you need to find +
    +
  • Watch closely while the cards are revealed.
  • +
  • After they flip face-down, click the three cards that showed the Primary image.
  • +
  • A wrong guess restarts the round after a brief delay — no partial credit.
  • +
  • Find all three to complete a round — complete 3 rounds in a row + to advance to the next level with a larger grid and shorter reveal time!
  • +
  • Use Tab to move between cards and Enter or Space + to select.
  • +
+ +
+ + + + + + + +
diff --git a/app/games/high-speed-memory/manifest.json b/app/games/high-speed-memory/manifest.json new file mode 100644 index 0000000..3f116d1 --- /dev/null +++ b/app/games/high-speed-memory/manifest.json @@ -0,0 +1,9 @@ +{ + "id": "high-speed-memory", + "name": "High Speed Memory", + "description": "Memorize the grid of cards, then find all the matching pairs from memory!", + "version": "1.0.0", + "entryPoint": "index.js", + "thumbnail": "images/thumbnail.jpg", + "author": "BrainSpeed Exercises" +} diff --git a/app/games/high-speed-memory/style.css b/app/games/high-speed-memory/style.css new file mode 100644 index 0000000..9e7bb69 --- /dev/null +++ b/app/games/high-speed-memory/style.css @@ -0,0 +1,274 @@ +/* ── Section wrapper ─────────────────────────────────────────── */ +.high-speed-memory { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.75rem; + padding: 0.75rem 1rem; + background-color: #f8f9fa; + color: #212529; /* ~14.5:1 contrast on #f8f9fa */ + /* Fill the full height of the game container */ + min-height: calc(100vh - 160px); + box-sizing: border-box; +} + +.high-speed-memory h2 { + font-size: 1.5rem; + font-weight: 700; + margin: 0; +} + +/* ── Instructions panel ──────────────────────────────────────── */ +.hsm-instructions { + max-width: 540px; + background-color: #ffffff; + border: 1px solid #dee2e6; + border-radius: 8px; + padding: 1.5rem 2rem; + text-align: left; +} + +.hsm-instructions h3 { + font-size: 1.25rem; + font-weight: 700; + margin: 0 0 0.75rem; +} + +.hsm-instructions p { + margin: 0 0 0.75rem; +} + +.hsm-instructions ul { + margin: 0 0 1.25rem 1.25rem; + padding: 0; +} + +.hsm-instructions li { + margin-bottom: 0.4rem; +} + +.hsm-instructions kbd { + display: inline-block; + padding: 0 0.35em; + font-family: monospace; + font-size: 0.875em; + border: 1px solid #adb5bd; + border-radius: 3px; + background-color: #f1f3f5; +} + +.hsm-instructions__primary-label { + margin: 0.75rem 0 0.25rem; + text-align: center; +} + +.hsm-instructions__primary-img { + display: block; + width: 120px; + height: 120px; + object-fit: cover; + border-radius: 6px; + border: 3px solid #28a745; + margin: 0 auto 1rem; +} + +.hsm-instructions .hsm-btn { + display: block; + width: 100%; + padding: 0.65rem 1.5rem; + font-size: 1.1rem; +} + +/* ── Game area ───────────────────────────────────────────────── */ +#hsm-game-area { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.75rem; + flex: 1; + width: 100%; + min-height: 0; +} + +/* ── Stats bar ───────────────────────────────────────────────── */ +.hsm-stats { + display: flex; + gap: 2rem; + font-size: 1rem; + font-weight: 500; + flex-wrap: wrap; + justify-content: center; +} + +.hsm-stat strong { + font-size: 1.25rem; +} + +/* ── Card grid ───────────────────────────────────────────────── */ +.hsm-grid { + display: grid; + gap: 6px; + /* vmin = min(vw, vh) — creates a square that fits within the viewport */ + width: 70vmin; + height: 70vmin; + min-width: 200px; + min-height: 200px; + max-width: 90vw; + max-height: 80vh; + margin: 0 auto; + flex-shrink: 0; +} + +/* ── Individual card ─────────────────────────────────────────── */ +.hsm-card { + position: relative; + border: none; + border-radius: 6px; + cursor: pointer; + overflow: hidden; + background-color: #1a1a2e; + transition: transform 0.1s ease, background-color 0.15s ease; + display: flex; + align-items: center; + justify-content: center; + padding: 0; +} + +/* Card image fills the button */ +.hsm-card__img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; + pointer-events: none; + user-select: none; +} + +/* Face-up revealed state */ +.hsm-card--revealed { + background-color: #ffffff; +} + +/* Matched pair: green background */ +.hsm-card--matched { + background-color: #d4edda; + cursor: default; + outline: 2px solid #28a745; +} + +.hsm-card--matched .hsm-card__img { + opacity: 0.85; +} + +/* Wrong guess: red tint (no animation per game spec) */ +.hsm-card--wrong { + background-color: #f8d7da; + outline: 2px solid #dc3545; +} + +/* Empty placeholder cell (fills unused grid slot) */ +.hsm-card--empty { + background-color: transparent; + border: 1px dashed #dee2e6; + cursor: default; + pointer-events: none; +} + +.hsm-card:hover:not(.hsm-card--matched):not(.hsm-card--empty):not([disabled]) { + transform: scale(1.04); +} + +.hsm-card:focus-visible { + outline: 3px solid #005fcc; + outline-offset: 3px; +} + +/* ── Controls ────────────────────────────────────────────────── */ +.hsm-controls { + display: flex; + gap: 1rem; + flex-wrap: wrap; + justify-content: center; +} + +/* ── Buttons ─────────────────────────────────────────────────── */ +.hsm-btn { + padding: 0.5rem 1.5rem; + font-size: 1rem; + font-weight: 600; + border: none; + border-radius: 4px; + cursor: pointer; + background-color: #005fcc; + color: #ffffff; /* 7.3:1 contrast on #005fcc */ + transition: background-color 0.15s ease; +} + +.hsm-btn:hover { + background-color: #004aa3; +} + +.hsm-btn:active { + background-color: #003d88; +} + +.hsm-btn:focus-visible { + outline: 3px solid #005fcc; + outline-offset: 3px; + background-color: #004aa3; +} + +.hsm-btn--secondary { + background-color: #6c757d; /* 4.6:1 on #ffffff */ + color: #ffffff; +} + +.hsm-btn--secondary:hover { + background-color: #545b62; +} + +.hsm-btn--secondary:active { + background-color: #3d4349; +} + +.hsm-btn--secondary:focus-visible { + outline-color: #6c757d; +} + +/* ── End-game panel ──────────────────────────────────────────── */ +.hsm-end-panel { + max-width: 360px; + background-color: #ffffff; + border: 1px solid #dee2e6; + border-radius: 8px; + padding: 1.5rem 2rem; + text-align: center; +} + +.hsm-end-panel h3 { + font-size: 1.5rem; + font-weight: 700; + margin: 0 0 1rem; +} + +.hsm-end-panel p { + font-size: 1.1rem; + margin: 0 0 0.5rem; +} + +.hsm-end-panel__actions { + display: flex; + gap: 0.75rem; + flex-wrap: wrap; + justify-content: center; + margin-top: 1rem; +} + +.hsm-end-panel .hsm-btn { + padding: 0.65rem 1.5rem; +} + +/* ── Respect hidden attribute ────────────────────────────────── */ +[hidden] { + display: none !important; +} diff --git a/app/games/high-speed-memory/tests/game.test.js b/app/games/high-speed-memory/tests/game.test.js new file mode 100644 index 0000000..a41748c --- /dev/null +++ b/app/games/high-speed-memory/tests/game.test.js @@ -0,0 +1,422 @@ +/** @jest-environment node */ +import { + describe, test, expect, beforeEach, +} from '@jest/globals'; + +import { + PRIMARY_IMAGE, + DISTRACTOR_IMAGES, + PRIMARY_COUNT, + ROUNDS_TO_LEVEL_UP, + BASE_DISPLAY_MS, + DISPLAY_DECREMENT_MS, + MIN_DISPLAY_MS, + initGame, + startGame, + stopGame, + getGridSize, + getDisplayDurationMs, + generateGrid, + isPrimary, + addCorrectGroup, + completeRound, + resetConsecutiveRounds, + getScore, + getLevel, + getRoundsCompleted, + getConsecutiveCorrectRounds, + isRunning, +} from '../game.js'; + +beforeEach(() => { + initGame(); +}); + +// ── Constants ───────────────────────────────────────────────────────────────── + +describe('PRIMARY_IMAGE', () => { + test('is a non-empty string', () => { + expect(typeof PRIMARY_IMAGE).toBe('string'); + expect(PRIMARY_IMAGE.length).toBeGreaterThan(0); + }); + + test('is Primary.jpg', () => { + expect(PRIMARY_IMAGE).toBe('Primary.jpg'); + }); +}); + +describe('DISTRACTOR_IMAGES', () => { + test('is a non-empty array of strings', () => { + expect(Array.isArray(DISTRACTOR_IMAGES)).toBe(true); + expect(DISTRACTOR_IMAGES.length).toBeGreaterThan(0); + DISTRACTOR_IMAGES.forEach((s) => expect(typeof s).toBe('string')); + }); + + test('does not contain the PRIMARY_IMAGE', () => { + expect(DISTRACTOR_IMAGES).not.toContain(PRIMARY_IMAGE); + }); +}); + +describe('PRIMARY_COUNT', () => { + test('is 3', () => { + expect(PRIMARY_COUNT).toBe(3); + }); +}); + +describe('ROUNDS_TO_LEVEL_UP', () => { + test('is 3', () => { + expect(ROUNDS_TO_LEVEL_UP).toBe(3); + }); +}); + +describe('display timing constants', () => { + test('BASE_DISPLAY_MS is 500', () => { + expect(BASE_DISPLAY_MS).toBe(1500); + }); + + test('MIN_DISPLAY_MS is 20', () => { + expect(MIN_DISPLAY_MS).toBe(20); + }); + + test('DISPLAY_DECREMENT_MS is a positive number', () => { + expect(DISPLAY_DECREMENT_MS).toBeGreaterThan(0); + }); +}); + +// ── initGame ────────────────────────────────────────────────────────────────── + +describe('initGame', () => { + test('resets score to 0', () => { + addCorrectGroup(); + initGame(); + expect(getScore()).toBe(0); + }); + + test('resets level to 0', () => { + // Need 3 completeRound calls to advance level + for (let i = 0; i < ROUNDS_TO_LEVEL_UP; i += 1) { + completeRound(); + } + initGame(); + expect(getLevel()).toBe(0); + }); + + test('resets roundsCompleted to 0', () => { + completeRound(); + initGame(); + expect(getRoundsCompleted()).toBe(0); + }); + + test('resets consecutiveCorrectRounds to 0', () => { + completeRound(); + initGame(); + expect(getConsecutiveCorrectRounds()).toBe(0); + }); + + test('resets running to false', () => { + startGame(); + initGame(); + expect(isRunning()).toBe(false); + }); +}); + +// ── startGame ───────────────────────────────────────────────────────────────── + +describe('startGame', () => { + test('sets running to true', () => { + startGame(); + expect(isRunning()).toBe(true); + }); + + test('throws if called when already running', () => { + startGame(); + expect(() => startGame()).toThrow('already running'); + }); +}); + +// ── stopGame ────────────────────────────────────────────────────────────────── + +describe('stopGame', () => { + test('sets running to false', () => { + startGame(); + stopGame(); + expect(isRunning()).toBe(false); + }); + + test('returns score, level, roundsCompleted, and duration', () => { + startGame(); + const result = stopGame(); + expect(result).toMatchObject({ + score: 0, + level: 0, + roundsCompleted: 0, + }); + expect(typeof result.duration).toBe('number'); + expect(result.duration).toBeGreaterThanOrEqual(0); + }); + + test('throws if the game is not running', () => { + expect(() => stopGame()).toThrow('not running'); + }); + + test('includes the current score in the result', () => { + startGame(); + addCorrectGroup(); + addCorrectGroup(); + const result = stopGame(); + expect(result.score).toBe(2); + }); + + test('includes the current level in the result', () => { + // Level advances after ROUNDS_TO_LEVEL_UP consecutive correct rounds + for (let i = 0; i < ROUNDS_TO_LEVEL_UP; i += 1) { + completeRound(); + } + startGame(); + const result = stopGame(); + expect(result.level).toBe(1); + }); +}); + +// ── getGridSize ─────────────────────────────────────────────────────────────── + +describe('getGridSize', () => { + test('returns 3×3 for level 0', () => { + expect(getGridSize(0)).toEqual({ rows: 3, cols: 3 }); + }); + + test('returns 4×4 for level 1', () => { + expect(getGridSize(1)).toEqual({ rows: 4, cols: 4 }); + }); + + test('returns 5×5 for level 2', () => { + expect(getGridSize(2)).toEqual({ rows: 5, cols: 5 }); + }); + + test('rows always equal cols (square grid)', () => { + for (let i = 0; i < 10; i += 1) { + const { rows, cols } = getGridSize(i); + expect(rows).toBe(cols); + } + }); + + test('grid grows with each level', () => { + for (let i = 0; i < 9; i += 1) { + expect(getGridSize(i + 1).rows).toBeGreaterThan(getGridSize(i).rows); + } + }); +}); + +// ── getDisplayDurationMs ────────────────────────────────────────────────────── + +describe('getDisplayDurationMs', () => { + test('returns BASE_DISPLAY_MS at level 0', () => { + expect(getDisplayDurationMs(0)).toBe(BASE_DISPLAY_MS); + }); + + test('decreases by DISPLAY_DECREMENT_MS each level', () => { + expect(getDisplayDurationMs(1)).toBe(BASE_DISPLAY_MS - DISPLAY_DECREMENT_MS); + }); + + test('never goes below MIN_DISPLAY_MS', () => { + expect(getDisplayDurationMs(9999)).toBe(MIN_DISPLAY_MS); + }); + + test('reaches minimum at high levels', () => { + const levelsToMin = Math.ceil((BASE_DISPLAY_MS - MIN_DISPLAY_MS) / DISPLAY_DECREMENT_MS); + expect(getDisplayDurationMs(levelsToMin + 5)).toBe(MIN_DISPLAY_MS); + }); +}); + +// ── generateGrid ────────────────────────────────────────────────────────────── + +describe('generateGrid', () => { + test('returns rows×cols cards (full grid)', () => { + const { rows, cols } = getGridSize(0); + expect(generateGrid(0).length).toBe(rows * cols); + }); + + test('contains exactly PRIMARY_COUNT copies of PRIMARY_IMAGE', () => { + const grid = generateGrid(0); + const primaryCards = grid.filter((c) => c.image === PRIMARY_IMAGE); + expect(primaryCards.length).toBe(PRIMARY_COUNT); + }); + + test('all non-Primary cards use DISTRACTOR_IMAGES', () => { + const grid = generateGrid(0); + grid.filter((c) => c.image !== PRIMARY_IMAGE).forEach((c) => { + expect(DISTRACTOR_IMAGES).toContain(c.image); + }); + }); + + test('all cards start as unmatched', () => { + const grid = generateGrid(0); + grid.forEach((card) => expect(card.matched).toBe(false)); + }); + + test('card ids are sequential 0-based indices', () => { + const grid = generateGrid(0); + grid.forEach((card, i) => expect(card.id).toBe(i)); + }); + + test('grid is full-sized at level 1 (4×4 = 16 cards)', () => { + expect(generateGrid(1).length).toBe(16); + }); + + test('PRIMARY_COUNT primary cards present at higher levels', () => { + [1, 2, 3, 4].forEach((lvl) => { + const grid = generateGrid(lvl); + const primaries = grid.filter((c) => c.image === PRIMARY_IMAGE); + expect(primaries.length).toBe(PRIMARY_COUNT); + }); + }); +}); + +// ── isPrimary ───────────────────────────────────────────────────────────────── + +describe('isPrimary', () => { + test('returns true for PRIMARY_IMAGE', () => { + expect(isPrimary(PRIMARY_IMAGE)).toBe(true); + }); + + test('returns false for each DISTRACTOR_IMAGE', () => { + DISTRACTOR_IMAGES.forEach((img) => { + expect(isPrimary(img)).toBe(false); + }); + }); + + test('returns false for an empty string', () => { + expect(isPrimary('')).toBe(false); + }); +}); + +// ── addCorrectGroup ─────────────────────────────────────────────────────────── + +describe('addCorrectGroup', () => { + test('increments score by 1', () => { + addCorrectGroup(); + expect(getScore()).toBe(1); + }); + + test('accumulates across multiple calls', () => { + addCorrectGroup(); + addCorrectGroup(); + addCorrectGroup(); + expect(getScore()).toBe(3); + }); +}); + +// ── completeRound ───────────────────────────────────────────────────────────── + +describe('completeRound', () => { + test('increments roundsCompleted by 1', () => { + completeRound(); + expect(getRoundsCompleted()).toBe(1); + }); + + test('does not advance level until ROUNDS_TO_LEVEL_UP consecutive rounds', () => { + completeRound(); + expect(getLevel()).toBe(0); + completeRound(); + expect(getLevel()).toBe(0); + }); + + test('advances level after ROUNDS_TO_LEVEL_UP consecutive correct rounds', () => { + for (let i = 0; i < ROUNDS_TO_LEVEL_UP; i += 1) { + completeRound(); + } + expect(getLevel()).toBe(1); + }); + + test('resets consecutiveCorrectRounds to 0 after level advance', () => { + for (let i = 0; i < ROUNDS_TO_LEVEL_UP; i += 1) { + completeRound(); + } + expect(getConsecutiveCorrectRounds()).toBe(0); + }); + + test('accumulates across multiple level advances', () => { + for (let i = 0; i < ROUNDS_TO_LEVEL_UP * 2; i += 1) { + completeRound(); + } + expect(getLevel()).toBe(2); + expect(getRoundsCompleted()).toBe(ROUNDS_TO_LEVEL_UP * 2); + }); +}); + +// ── resetConsecutiveRounds ─────────────────────────────────────────────────── + +describe('resetConsecutiveRounds', () => { + test('resets the consecutive correct round counter to 0', () => { + completeRound(); + expect(getConsecutiveCorrectRounds()).toBe(1); + resetConsecutiveRounds(); + expect(getConsecutiveCorrectRounds()).toBe(0); + }); + + test('prevents level advance when streak is broken between rounds', () => { + completeRound(); + completeRound(); + resetConsecutiveRounds(); // streak broken: back to 0 + completeRound(); // only 1 consecutive now + expect(getLevel()).toBe(0); + expect(getConsecutiveCorrectRounds()).toBe(1); + }); +}); + +// ── getScore / getLevel / getRoundsCompleted / isRunning ────────────────────── + +describe('getScore', () => { + test('returns 0 after init', () => { + expect(getScore()).toBe(0); + }); +}); + +describe('getLevel', () => { + test('returns 0 after init', () => { + expect(getLevel()).toBe(0); + }); +}); + +describe('getRoundsCompleted', () => { + test('returns 0 after init', () => { + expect(getRoundsCompleted()).toBe(0); + }); +}); + +describe('getConsecutiveCorrectRounds', () => { + test('returns 0 after init', () => { + expect(getConsecutiveCorrectRounds()).toBe(0); + }); + + test('increments with each completeRound call', () => { + completeRound(); + expect(getConsecutiveCorrectRounds()).toBe(1); + completeRound(); + expect(getConsecutiveCorrectRounds()).toBe(2); + }); + + test('resets to 0 when level advances', () => { + for (let i = 0; i < ROUNDS_TO_LEVEL_UP; i += 1) { + completeRound(); + } + expect(getConsecutiveCorrectRounds()).toBe(0); + }); +}); + +describe('isRunning', () => { + test('returns false before startGame', () => { + expect(isRunning()).toBe(false); + }); + + test('returns true after startGame', () => { + startGame(); + expect(isRunning()).toBe(true); + }); + + test('returns false after stopGame', () => { + startGame(); + stopGame(); + expect(isRunning()).toBe(false); + }); +}); diff --git a/app/games/high-speed-memory/tests/index.test.js b/app/games/high-speed-memory/tests/index.test.js new file mode 100644 index 0000000..85a0bff --- /dev/null +++ b/app/games/high-speed-memory/tests/index.test.js @@ -0,0 +1,748 @@ +import { jest, describe, test, expect, beforeEach, afterEach } from '@jest/globals'; + +// Mock game.js so index.js can be tested in isolation. +jest.unstable_mockModule('../game.js', () => ({ + PRIMARY_IMAGE: 'Primary.jpg', + DISTRACTOR_IMAGES: ['Distractor1.jpg', 'Distractor2.jpg'], + PRIMARY_COUNT: 3, + ROUNDS_TO_LEVEL_UP: 3, + BASE_DISPLAY_MS: 500, + DISPLAY_DECREMENT_MS: 24, + MIN_DISPLAY_MS: 20, + initGame: jest.fn(), + startGame: jest.fn(), + stopGame: jest.fn(() => ({ score: 5, level: 2, roundsCompleted: 6, duration: 12000 })), + getGridSize: jest.fn(() => ({ rows: 3, cols: 3 })), + getDisplayDurationMs: jest.fn(() => 500), + // 3×3 grid: cards 0, 4, 8 are Primary; rest are Distractors + generateGrid: jest.fn(() => [ + { id: 0, image: 'Primary.jpg', matched: false }, + { id: 1, image: 'Distractor1.jpg', matched: false }, + { id: 2, image: 'Distractor2.jpg', matched: false }, + { id: 3, image: 'Distractor1.jpg', matched: false }, + { id: 4, image: 'Primary.jpg', matched: false }, + { id: 5, image: 'Distractor2.jpg', matched: false }, + { id: 6, image: 'Distractor1.jpg', matched: false }, + { id: 7, image: 'Distractor2.jpg', matched: false }, + { id: 8, image: 'Primary.jpg', matched: false }, + ]), + isPrimary: jest.fn((img) => img === 'Primary.jpg'), + addCorrectGroup: jest.fn(), + completeRound: jest.fn(), + resetConsecutiveRounds: jest.fn(), + getScore: jest.fn(() => 5), + getLevel: jest.fn(() => 2), + getRoundsCompleted: jest.fn(() => 6), + getConsecutiveCorrectRounds: jest.fn(() => 1), + isRunning: jest.fn(() => false), +})); + +const pluginModule = await import('../index.js'); +const plugin = pluginModule.default; +const { + announce, + updateStats, + updateFoundDisplay, + renderGrid, + hideCardEl, + revealCardEl, + markCardMatched, + markCardWrong, + hideAllCards, + startRound, + handleCardClick, + playWrongSound, +} = pluginModule; + +const gameMock = await import('../game.js'); + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +/** Build a minimal DOM matching interface.html. */ +function buildContainer() { + const el = document.createElement('div'); + el.innerHTML = ` +
+ + + + + + +
+ 0 + 1 + 0 + 0 +
+ 0 + 1 + `; + return el; +} + +// ── Plugin contract ─────────────────────────────────────────────────────────── + +describe('plugin contract', () => { + test('exposes a string name', () => { + expect(typeof plugin.name).toBe('string'); + expect(plugin.name.length).toBeGreaterThan(0); + }); + + test('exposes init, start, stop, and reset functions', () => { + expect(typeof plugin.init).toBe('function'); + expect(typeof plugin.start).toBe('function'); + expect(typeof plugin.stop).toBe('function'); + expect(typeof plugin.reset).toBe('function'); + }); +}); + +// ── init ────────────────────────────────────────────────────────────────────── + +describe('init', () => { + test('accepts a DOM container without throwing', () => { + expect(() => plugin.init(buildContainer())).not.toThrow(); + }); + + test('accepts null without throwing', () => { + expect(() => plugin.init(null)).not.toThrow(); + }); +}); + +// ── start ───────────────────────────────────────────────────────────────────── + +describe('start', () => { + let container; + + beforeEach(() => { + jest.useFakeTimers(); + container = buildContainer(); + plugin.init(container); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + test('shows the game area and hides instructions', () => { + plugin.start(); + expect(container.querySelector('#hsm-game-area').hidden).toBe(false); + expect(container.querySelector('#hsm-instructions').hidden).toBe(true); + }); + + test('does not throw when called without a container', () => { + plugin.init(null); + expect(() => plugin.start()).not.toThrow(); + }); + + test('start button click triggers start', () => { + const startBtn = container.querySelector('#hsm-start-btn'); + startBtn.click(); + expect(container.querySelector('#hsm-game-area').hidden).toBe(false); + }); +}); + +// ── stop ────────────────────────────────────────────────────────────────────── + +describe('stop', () => { + let container; + + beforeEach(() => { + jest.useFakeTimers(); + container = buildContainer(); + plugin.init(container); + plugin.start(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + test('returns the result from game logic', () => { + const result = plugin.stop(); + expect(result).toMatchObject({ score: 5, level: 2 }); + }); + + test('shows the end panel', () => { + plugin.stop(); + expect(container.querySelector('#hsm-end-panel').hidden).toBe(false); + }); + + test('updates the final score display', () => { + plugin.stop(); + expect(container.querySelector('#hsm-final-score').textContent).toBe('5'); + }); + + test('does not throw when container is null', () => { + plugin.init(null); + expect(() => plugin.stop()).not.toThrow(); + }); + + test('stop button click triggers stop', () => { + const stopBtn = container.querySelector('#hsm-stop-btn'); + stopBtn.click(); + expect(container.querySelector('#hsm-end-panel').hidden).toBe(false); + }); + + test('clears pending round-restart timer on stop', () => { + jest.runAllTimers(); // release flip lock + handleCardClick(1); // Distractor — triggers round-restart timer + expect(() => plugin.stop()).not.toThrow(); + }); + + test('invokes window.api.invoke with correct progress:save format', async () => { + const mockApi = { + invoke: jest.fn() + .mockResolvedValueOnce({ playerId: 'default', games: {} }) + .mockResolvedValueOnce(undefined), + }; + globalThis.window = globalThis.window || {}; + const originalApi = globalThis.window.api; + globalThis.window.api = mockApi; + + plugin.stop(); + await Promise.resolve(); + await Promise.resolve(); + + expect(mockApi.invoke).toHaveBeenCalledWith( + 'progress:save', + expect.objectContaining({ + playerId: 'default', + data: expect.objectContaining({ + games: expect.objectContaining({ + 'high-speed-memory': expect.objectContaining({ + sessionsPlayed: expect.any(Number), + }), + }), + }), + }), + ); + globalThis.window.api = originalApi; + }); + + test('swallows errors from window.api.invoke', async () => { + const mockApi = { invoke: jest.fn().mockRejectedValue(new Error('ipc error')) }; + globalThis.window = globalThis.window || {}; + const originalApi = globalThis.window.api; + globalThis.window.api = mockApi; + + expect(() => plugin.stop()).not.toThrow(); + await Promise.resolve(); + + globalThis.window.api = originalApi; + }); +}); + +// ── reset ───────────────────────────────────────────────────────────────────── + +describe('reset', () => { + let container; + + beforeEach(() => { + jest.useFakeTimers(); + container = buildContainer(); + plugin.init(container); + plugin.start(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + test('hides the game area', () => { + plugin.reset(); + expect(container.querySelector('#hsm-game-area').hidden).toBe(true); + }); + + test('shows the instructions panel', () => { + plugin.reset(); + expect(container.querySelector('#hsm-instructions').hidden).toBe(false); + }); + + test('does not throw when container is null', () => { + plugin.init(null); + expect(() => plugin.reset()).not.toThrow(); + }); + + test('clears pending hide timer on reset', () => { + expect(() => plugin.reset()).not.toThrow(); + }); + + test('clears pending round-restart timer on reset', () => { + jest.runAllTimers(); // release flip lock + handleCardClick(1); // Distractor — triggers round-restart timer + expect(() => plugin.reset()).not.toThrow(); + }); +}); + +// ── play-again button ───────────────────────────────────────────────────────── + +describe('play again button', () => { + test('resets and restarts the game', () => { + jest.useFakeTimers(); + const container = buildContainer(); + plugin.init(container); + plugin.start(); + plugin.stop(); + + const playAgainBtn = container.querySelector('#hsm-play-again-btn'); + playAgainBtn.click(); + + expect(container.querySelector('#hsm-game-area').hidden).toBe(false); + jest.useRealTimers(); + }); +}); + +// ── return-to-menu button ───────────────────────────────────────────────────── + +describe('return to menu button', () => { + test('dispatches bsx:return-to-main-menu event when clicked', () => { + jest.useFakeTimers(); + const container = buildContainer(); + plugin.init(container); + plugin.start(); + plugin.stop(); + + let eventFired = false; + const handler = () => { eventFired = true; }; + window.addEventListener('bsx:return-to-main-menu', handler, { once: true }); + + const returnBtn = container.querySelector('#hsm-return-btn'); + returnBtn.click(); + + expect(eventFired).toBe(true); + jest.useRealTimers(); + }); +}); + +// ── playWrongSound ──────────────────────────────────────────────────────────── + +describe('playWrongSound', () => { + test('does not throw when AudioContext is unavailable', () => { + expect(() => playWrongSound()).not.toThrow(); + }); + + test('creates and plays an oscillator when AudioContext is available', () => { + const mockOsc = { + connect: jest.fn(), + type: '', + frequency: { setValueAtTime: jest.fn() }, + gain: { setValueAtTime: jest.fn(), exponentialRampToValueAtTime: jest.fn() }, + start: jest.fn(), + stop: jest.fn(), + onended: null, + }; + const mockGainNode = { + connect: jest.fn(), + gain: { setValueAtTime: jest.fn(), exponentialRampToValueAtTime: jest.fn() }, + }; + const mockCtx = { + createOscillator: jest.fn(() => mockOsc), + createGain: jest.fn(() => mockGainNode), + destination: {}, + currentTime: 0, + close: jest.fn().mockResolvedValue(undefined), + }; + const OriginalAudioContext = globalThis.AudioContext; + globalThis.AudioContext = jest.fn(() => mockCtx); + + expect(() => playWrongSound()).not.toThrow(); + expect(mockOsc.start).toHaveBeenCalled(); + if (mockOsc.onended) mockOsc.onended(); + + globalThis.AudioContext = OriginalAudioContext; + }); + + test('swallows errors thrown by the audio context', () => { + const OriginalAudioContext = globalThis.AudioContext; + globalThis.AudioContext = jest.fn(() => { throw new Error('Audio unavailable'); }); + + expect(() => playWrongSound()).not.toThrow(); + + globalThis.AudioContext = OriginalAudioContext; + }); +}); + +// ── announce ────────────────────────────────────────────────────────────────── + +describe('announce', () => { + test('sets feedback element text content', () => { + const container = buildContainer(); + plugin.init(container); + announce('Test message'); + expect(container.querySelector('#hsm-feedback').textContent).toBe('Test message'); + }); + + test('does not throw when feedback element is absent', () => { + plugin.init(document.createElement('div')); + expect(() => announce('hello')).not.toThrow(); + }); +}); + +// ── updateStats ─────────────────────────────────────────────────────────────── + +describe('updateStats', () => { + test('updates score and level elements', () => { + const container = buildContainer(); + plugin.init(container); + updateStats(); + expect(container.querySelector('#hsm-score').textContent).toBe('5'); + expect(container.querySelector('#hsm-level').textContent).toBe('3'); + }); + + test('updates streak element', () => { + const container = buildContainer(); + plugin.init(container); + updateStats(); + // getConsecutiveCorrectRounds mock returns 1 + expect(container.querySelector('#hsm-streak').textContent).toBe('1'); + }); + + test('does not throw when elements are absent', () => { + plugin.init(document.createElement('div')); + expect(() => updateStats()).not.toThrow(); + }); +}); + +// ── updateFoundDisplay ──────────────────────────────────────────────────────── + +describe('updateFoundDisplay', () => { + test('does not throw when found element is absent', () => { + plugin.init(document.createElement('div')); + expect(() => updateFoundDisplay()).not.toThrow(); + }); + + test('updates found element', () => { + const container = buildContainer(); + plugin.init(container); + updateFoundDisplay(); + expect(container.querySelector('#hsm-found').textContent).toBe('0'); + }); +}); + +// ── renderGrid ──────────────────────────────────────────────────────────────── + +describe('renderGrid', () => { + test('creates one button per card in the mocked grid', () => { + jest.useFakeTimers(); + const container = buildContainer(); + plugin.init(container); + startRound(); + const buttons = container.querySelectorAll('#hsm-grid button'); + expect(buttons.length).toBe(9); // 3×3 mock grid + jest.useRealTimers(); + }); + + test('does not throw when grid element is absent', () => { + plugin.init(document.createElement('div')); + expect(() => renderGrid()).not.toThrow(); + }); + + test('buttons have data-id attributes', () => { + jest.useFakeTimers(); + const container = buildContainer(); + plugin.init(container); + startRound(); + const btn = container.querySelector('[data-id="0"]'); + expect(btn).not.toBeNull(); + jest.useRealTimers(); + }); + + test('each card button contains an img element', () => { + jest.useFakeTimers(); + const container = buildContainer(); + plugin.init(container); + startRound(); + const btn = container.querySelector('[data-id="0"]'); + expect(btn.querySelector('img')).not.toBeNull(); + jest.useRealTimers(); + }); + + test('pressing Enter on a card triggers handleCardClick', () => { + jest.useFakeTimers(); + const container = buildContainer(); + plugin.init(container); + startRound(); + jest.runAllTimers(); // hide cards so flipLock is false + + const btn = container.querySelector('[data-id="0"]'); // Primary card + btn.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + expect(btn.classList.contains('hsm-card--matched')).toBe(true); + jest.useRealTimers(); + }); + + test('pressing Space on a card triggers handleCardClick', () => { + jest.useFakeTimers(); + const container = buildContainer(); + plugin.init(container); + startRound(); + jest.runAllTimers(); + + const btn = container.querySelector('[data-id="0"]'); // Primary card + btn.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', bubbles: true })); + expect(btn.classList.contains('hsm-card--matched')).toBe(true); + jest.useRealTimers(); + }); + + test('pressing other keys does not trigger handleCardClick', () => { + jest.useFakeTimers(); + const container = buildContainer(); + plugin.init(container); + startRound(); + jest.runAllTimers(); + + const btn = container.querySelector('[data-id="0"]'); // Primary card + btn.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true })); + // Primary card should NOT be matched since Tab was pressed + expect(btn.classList.contains('hsm-card--matched')).toBe(false); + jest.useRealTimers(); + }); +}); + +// ── hideCardEl / revealCardEl / markCardMatched / markCardWrong ─────────────── + +describe('card element manipulation', () => { + let container; + + beforeEach(() => { + jest.useFakeTimers(); + container = buildContainer(); + plugin.init(container); + startRound(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + test('hideCardEl removes revealed class', () => { + const btn = container.querySelector('[data-id="0"]'); + btn.classList.add('hsm-card--revealed'); + hideCardEl(0); + expect(btn.classList.contains('hsm-card--revealed')).toBe(false); + }); + + test('hideCardEl hides the img element and updates aria-label', () => { + const btn = container.querySelector('[data-id="0"]'); + hideCardEl(0); + const img = btn.querySelector('img'); + expect(img.style.display).toBe('none'); + expect(btn.getAttribute('aria-label')).toContain('face down'); + }); + + test('revealCardEl adds revealed class', () => { + const btn = container.querySelector('[data-id="0"]'); + revealCardEl(0, 'Primary.jpg'); + expect(btn.classList.contains('hsm-card--revealed')).toBe(true); + }); + + test('revealCardEl un-hides the img element and sets the correct src', () => { + const btn = container.querySelector('[data-id="0"]'); + hideCardEl(0); + revealCardEl(0, 'Primary.jpg'); + const img = btn.querySelector('img'); + expect(img.style.display).toBe(''); + expect(img.src).toContain('Primary.jpg'); + }); + + test('markCardMatched adds matched class, disables button, and updates aria-label', () => { + markCardMatched(0); + const btn = container.querySelector('[data-id="0"]'); + expect(btn.classList.contains('hsm-card--matched')).toBe(true); + expect(btn.disabled).toBe(true); + expect(btn.getAttribute('aria-label')).toContain('matched'); + }); + + test('markCardWrong adds wrong class', () => { + markCardWrong(1); + const btn = container.querySelector('[data-id="1"]'); + expect(btn.classList.contains('hsm-card--wrong')).toBe(true); + }); + + test('hideCardEl does not throw for unknown card id', () => { + expect(() => hideCardEl(9999)).not.toThrow(); + }); + + test('revealCardEl does not throw for unknown card id', () => { + expect(() => revealCardEl(9999, 'Primary.jpg')).not.toThrow(); + }); + + test('markCardMatched does not throw for unknown card id', () => { + expect(() => markCardMatched(9999)).not.toThrow(); + }); + + test('markCardWrong does not throw for unknown card id', () => { + expect(() => markCardWrong(9999)).not.toThrow(); + }); +}); + +// ── hideAllCards ────────────────────────────────────────────────────────────── + +describe('hideAllCards', () => { + test('hides all un-matched cards', () => { + jest.useFakeTimers(); + const container = buildContainer(); + plugin.init(container); + startRound(); + hideAllCards(); + // All cards should be face-down (no hsm-card--revealed class) + const cards = container.querySelectorAll('#hsm-grid .hsm-card'); + cards.forEach((btn) => { + expect(btn.classList.contains('hsm-card--revealed')).toBe(false); + }); + jest.useRealTimers(); + }); + + test('allows card clicks after reveal phase (flip lock released)', () => { + jest.useFakeTimers(); + const container = buildContainer(); + plugin.init(container); + startRound(); + hideAllCards(); // flip lock should now be false + // A Primary card click should now be processed (not blocked) + handleCardClick(0); + const btn = container.querySelector('[data-id="0"]'); + expect(btn.classList.contains('hsm-card--matched')).toBe(true); + jest.useRealTimers(); + }); + + test('does not throw when container is absent', () => { + plugin.init(document.createElement('div')); + expect(() => hideAllCards()).not.toThrow(); + }); +}); + +// ── startRound ──────────────────────────────────────────────────────────────── + +describe('startRound', () => { + test('populates the grid with card buttons', () => { + jest.useFakeTimers(); + const container = buildContainer(); + plugin.init(container); + startRound(); + expect(container.querySelectorAll('#hsm-grid button').length).toBeGreaterThan(0); + jest.useRealTimers(); + }); + + test('sets flip lock during the reveal phase', () => { + jest.useFakeTimers(); + const container = buildContainer(); + plugin.init(container); + startRound(); + // During reveal, a Primary card click should be ignored (flip lock active) + const btn = container.querySelector('[data-id="0"]'); + expect(btn.classList.contains('hsm-card--matched')).toBe(false); + jest.useRealTimers(); + }); + + test('hides cards after the display duration', () => { + jest.useFakeTimers(); + const container = buildContainer(); + plugin.init(container); + startRound(); + jest.runAllTimers(); + // All cards should now be face-down + const cards = container.querySelectorAll('#hsm-grid .hsm-card'); + cards.forEach((btn) => { + expect(btn.classList.contains('hsm-card--revealed')).toBe(false); + }); + jest.useRealTimers(); + }); +}); + +// ── handleCardClick ─────────────────────────────────────────────────────────── + +describe('handleCardClick', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + test('ignores clicks when flip lock is active', () => { + const container = buildContainer(); + plugin.init(container); + startRound(); // flip lock active during reveal + expect(() => handleCardClick(0)).not.toThrow(); + // flip lock prevents card from being matched + const btn = container.querySelector('[data-id="0"]'); + expect(btn.classList.contains('hsm-card--matched')).toBe(false); + }); + + test('ignores clicks on matched cards', () => { + const container = buildContainer(); + plugin.init(container); + startRound(); + jest.runAllTimers(); // release flip lock + handleCardClick(0); // Primary → matched + expect(() => handleCardClick(0)).not.toThrow(); // already matched + }); + + test('marks Primary card as matched on click', () => { + const container = buildContainer(); + plugin.init(container); + startRound(); + jest.runAllTimers(); // release flip lock + handleCardClick(0); // Primary card + const btn = container.querySelector('[data-id="0"]'); + expect(btn.classList.contains('hsm-card--matched')).toBe(true); + }); + + test('marks Distractor card with wrong class', () => { + const container = buildContainer(); + plugin.init(container); + startRound(); + jest.runAllTimers(); // release flip lock + handleCardClick(1); // Distractor1.jpg + const btn = container.querySelector('[data-id="1"]'); + expect(btn.classList.contains('hsm-card--wrong')).toBe(true); + }); + + test('calls resetConsecutiveRounds when a Distractor card is clicked', () => { + const container = buildContainer(); + plugin.init(container); + startRound(); + jest.runAllTimers(); // release flip lock + gameMock.resetConsecutiveRounds.mockClear(); + handleCardClick(1); // Distractor1.jpg — wrong guess + expect(gameMock.resetConsecutiveRounds).toHaveBeenCalledTimes(1); + }); + + test('does not call resetConsecutiveRounds when a Primary card is clicked', () => { + const container = buildContainer(); + plugin.init(container); + startRound(); + jest.runAllTimers(); // release flip lock + gameMock.resetConsecutiveRounds.mockClear(); + handleCardClick(0); // Primary.jpg — correct + expect(gameMock.resetConsecutiveRounds).not.toHaveBeenCalled(); + }); + + test('restarts the round after a wrong guess delay', () => { + const container = buildContainer(); + plugin.init(container); + startRound(); + jest.runAllTimers(); // release flip lock + gameMock.generateGrid.mockClear(); + handleCardClick(1); // Distractor — triggers round-restart timer + jest.runAllTimers(); // fires restart: calls startRound() → generateGrid() + expect(gameMock.generateGrid).toHaveBeenCalledTimes(1); + }); + + test('advances to next round when all PRIMARY_COUNT Primary cards found', () => { + const container = buildContainer(); + plugin.init(container); + startRound(); + jest.runAllTimers(); // release flip lock + + // Cards 0, 4, 8 are Primary in the mock grid + handleCardClick(0); + handleCardClick(4); + handleCardClick(8); // 3rd Primary → triggers onRoundComplete + + expect(gameMock.completeRound).toHaveBeenCalled(); + jest.runAllTimers(); // inter-round delay + }); +}); diff --git a/app/interface.js b/app/interface.js index 4cd6547..7c05d58 100644 --- a/app/interface.js +++ b/app/interface.js @@ -8,6 +8,47 @@ import { createGameCard } from './components/gameCard.js'; +/** + * Inject a game-specific stylesheet into the document . + * Replaces any previously injected game stylesheet so only one is active at a time. + * + * @param {string} gameId - The game ID; its style.css lives at games/{gameId}/style.css. + */ +function injectGameStylesheet(gameId) { + const existing = document.getElementById('active-game-stylesheet'); + if (existing) existing.remove(); + const link = document.createElement('link'); + link.id = 'active-game-stylesheet'; + link.rel = 'stylesheet'; + link.href = `./games/${gameId}/style.css`; + document.head.appendChild(link); +} + +/** + * Remove the active game stylesheet from the document . + * Called when returning to the main game-selection screen. + */ +function removeGameStylesheet() { + const existing = document.getElementById('active-game-stylesheet'); + if (existing) existing.remove(); +} + +/** + * Load a game into the game container and initialise its plugin. + * + * @param {string} gameId - The ID of the game to load. + * @param {HTMLElement} gameContainer - The element that will receive the game HTML. + * @param {HTMLElement} announcer - Aria-live element for accessibility announcements. + */ +async function loadAndInitGame(gameId, gameContainer, announcer) { + const result = await window.api.invoke('games:load', gameId); + gameContainer.innerHTML = result.html; + injectGameStylesheet(gameId); + announcer.textContent = `${result.manifest.name} loaded. Get ready to play!`; + const mod = await import(`./games/${gameId}/${result.manifest.entryPoint}`); + mod.default.init(gameContainer); +} + /** * DOMContentLoaded event handler. Sets up the game selection UI and plugin loader. * @returns {Promise} @@ -50,29 +91,20 @@ document.addEventListener('DOMContentLoaded', async () => { */ gameSelector.addEventListener('game:select', async (event) => { const { gameId } = event.detail; - const result = await window.api.invoke('games:load', gameId); - gameSelector.remove(); - gameContainer.innerHTML = result.html; - - announcer.textContent = `${result.manifest.name} loaded. Get ready to play!`; - - // Dynamically import the game plugin and initialise it so that the - // instructions panel and start button become active. - const mod = await import(`./games/${gameId}/${result.manifest.entryPoint}`); - mod.default.init(gameContainer); + await loadAndInitGame(gameId, gameContainer, announcer); }); // Listen for custom event to return to main menu from any game window.addEventListener('bsx:return-to-main-menu', () => { - // Remove any game UI + // Remove any game UI and its stylesheet gameContainer.innerHTML = ''; + removeGameStylesheet(); // Restore the game selector if (!document.getElementById('game-selector')) { const selector = document.createElement('section'); selector.id = 'game-selector'; selector.setAttribute('aria-label', 'Available games'); gameContainer.appendChild(selector); - // Re-render game cards // Reload progress and game cards Promise.all([ window.api.invoke('progress:load', { playerId: 'default' }), @@ -89,12 +121,8 @@ document.addEventListener('DOMContentLoaded', async () => { // Re-attach event listener for game selection selector.addEventListener('game:select', async (event) => { const { gameId } = event.detail; - const result = await window.api.invoke('games:load', gameId); selector.remove(); - gameContainer.innerHTML = result.html; - announcer.textContent = `${result.manifest.name} loaded. Get ready to play!`; - const mod = await import(`./games/${gameId}/${result.manifest.entryPoint}`); - mod.default.init(gameContainer); + await loadAndInitGame(gameId, gameContainer, announcer); }); } announcer.textContent = 'Main menu loaded. Select a game.';