diff --git a/app/components/gameCard.js b/app/components/gameCard.js index 40e6954..3808c08 100644 --- a/app/components/gameCard.js +++ b/app/components/gameCard.js @@ -16,6 +16,11 @@ * @param {string} [manifest.thumbnail] - Path to the thumbnail image. * @param {object} [progress] - Optional progress data for the game. * @param {number} [progress.highScore] - The player's high score for this game. + * @param {number} [progress.highestLevel] - The highest level reached (0-indexed; + * displayed as level + 1). + * @param {number} [progress.maxLevel] - The maximum level number reached. + * @param {number} [progress.maxPiggies] - The maximum number of piggies collected. + * @param {number} [progress.lowestDisplayTime] - The lowest display time achieved, in milliseconds. * @returns {HTMLElement} An
element representing the game card. */ export function createGameCard(manifest, progress) { @@ -36,18 +41,23 @@ export function createGameCard(manifest, progress) { const description = document.createElement('p'); description.textContent = manifest.description || ''; - // If this is Fast Piggie, show detailed stats if available + // Show saved stats when available. let scoreElem = null; - if (manifest.id === 'fast-piggie' && progress) { + if (progress) { scoreElem = document.createElement('p'); scoreElem.className = 'game-high-score'; const details = []; if (typeof progress.highScore === 'number') details.push(`Top Score: ${progress.highScore}`); + if (typeof progress.highestLevel === 'number') details.push(`Max Level: ${progress.highestLevel + 1}`); if (typeof progress.maxLevel === 'number') details.push(`Max Level: ${progress.maxLevel}`); if (typeof progress.maxPiggies === 'number') details.push(`Max Piggies: ${progress.maxPiggies}`); if (typeof progress.lowestDisplayTime === 'number') details.push(`Lowest Display Time: ${progress.lowestDisplayTime}ms`); - scoreElem.textContent = details.join(' | '); - scoreElem.setAttribute('aria-label', `Stats for ${manifest.name}: ${scoreElem.textContent}`); + if (details.length > 0) { + scoreElem.textContent = details.join(' | '); + scoreElem.setAttribute('aria-label', `Stats for ${manifest.name}: ${scoreElem.textContent}`); + } else { + scoreElem = null; + } } const button = document.createElement('button'); diff --git a/app/components/gameCard.test.js b/app/components/gameCard.test.js index 9f5e900..8b3d4d5 100644 --- a/app/components/gameCard.test.js +++ b/app/components/gameCard.test.js @@ -1,16 +1,3 @@ -it('displays high score for Fast Piggie when provided', () => { - const manifest = { - id: 'fast-piggie', - name: 'Fast Piggie', - description: 'Test desc', - thumbnail: '/images/test.png', - }; - const progress = { highScore: 42 }; - const card = createGameCard(manifest, progress); - const scoreElem = card.querySelector('.game-high-score'); - expect(scoreElem).not.toBeNull(); - expect(scoreElem.textContent).toContain('42'); -}); import { createGameCard } from './gameCard.js'; const validManifest = { @@ -87,4 +74,43 @@ describe('createGameCard', () => { const button = card.querySelector('button'); expect(button.getAttribute('aria-label')).toBeTruthy(); }); + + it('displays score stats for any game when provided', () => { + const manifest = { + id: 'orbit-sprite-memory', + name: 'Orbit Sprite Memory', + description: 'Test desc', + thumbnail: '/images/test.png', + }; + const progress = { highScore: 42, highestLevel: 3 }; + const card = createGameCard(manifest, progress); + const scoreElem = card.querySelector('.game-high-score'); + + expect(scoreElem).not.toBeNull(); + expect(scoreElem.textContent).toContain('Top Score: 42'); + expect(scoreElem.textContent).toContain('Max Level: 4'); + }); + + it('displays detailed fast-piggie stats when provided', () => { + const manifest = { + id: 'fast-piggie', + name: 'Fast Piggie', + description: 'Test desc', + thumbnail: '/images/test.png', + }; + const progress = { + highScore: 11, + maxLevel: 5, + maxPiggies: 9, + lowestDisplayTime: 550, + }; + const card = createGameCard(manifest, progress); + const scoreElem = card.querySelector('.game-high-score'); + + expect(scoreElem).not.toBeNull(); + expect(scoreElem.textContent).toContain('Top Score: 11'); + expect(scoreElem.textContent).toContain('Max Level: 5'); + expect(scoreElem.textContent).toContain('Max Piggies: 9'); + expect(scoreElem.textContent).toContain('Lowest Display Time: 550ms'); + }); }); diff --git a/app/games/orbit-sprite-memory/game.js b/app/games/orbit-sprite-memory/game.js new file mode 100644 index 0000000..72a6115 --- /dev/null +++ b/app/games/orbit-sprite-memory/game.js @@ -0,0 +1,325 @@ +/** + * game.js — Pure game logic for Orbit Sprite Memory. + * + * Generates rounds where one target sprite appears exactly three times, + * distractors appear up to two times each, and level progression is streak-based. + * + * @file Orbit Sprite Memory game logic module. + */ + +/** Number of sprites in the provided 4x2 sheet. */ +export const TOTAL_SPRITES = 8; + +/** Number of columns in the provided sprite sheet. */ +export const SPRITE_COLUMNS = 4; + +/** Number of rows in the provided sprite sheet. */ +export const SPRITE_ROWS = 2; + +/** Maximum number of circle positions / images shown per round. */ +export const MAX_POSITION_COUNT = 12; + +/** Target sprite appears exactly this many times per round. */ +export const PRIMARY_SHOW_COUNT = 3; + +/** Any distractor can appear at most this many times per round. */ +export const MAX_DISTRACTOR_SHOWS = 2; + +/** Correct rounds in a row needed for a level increase. */ +export const STREAK_TO_LEVEL_UP = 3; + +/** Base display duration at level 0, in ms. */ +export const BASE_DISPLAY_MS = 1100; + +/** Display duration reduction per level, in ms. */ +export const DISPLAY_DECREMENT_MS = 90; + +/** Minimum image display duration, in ms. */ +export const MIN_DISPLAY_MS = 250; + +/** Distractor count at level 0. */ +export const BASE_DISTRACTOR_COUNT = 2; + +/** @type {number} */ +let score = 0; + +/** @type {number} */ +let level = 0; + +/** @type {number} */ +let roundsPlayed = 0; + +/** @type {number} */ +let consecutiveCorrect = 0; + +/** @type {boolean} */ +let running = false; + +/** @type {number|null} */ +let startTime = null; + +/** + * Resets all gameplay state. + */ +export function initGame() { + score = 0; + level = 0; + roundsPlayed = 0; + consecutiveCorrect = 0; + running = false; + startTime = null; +} + +/** + * Starts 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(); +} + +/** + * Stops the game and returns summary stats. + * @returns {{ score: number, level: number, roundsPlayed: 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, + roundsPlayed, + duration, + }; +} + +/** + * Returns display duration for a given level. + * + * @param {number} lvl - Zero-based level. + * @returns {number} + */ +export function getDisplayDurationMs(lvl) { + return Math.max(BASE_DISPLAY_MS - lvl * DISPLAY_DECREMENT_MS, MIN_DISPLAY_MS); +} + +/** + * Returns how many unique distractor sprites are used this level. + * + * @param {number} lvl - Zero-based level. + * @returns {number} + */ +export function getDistractorCount(lvl) { + const maxDistractors = TOTAL_SPRITES - 1; + return Math.min(BASE_DISTRACTOR_COUNT + lvl, maxDistractors); +} + +/** + * Creates a shuffled copy of an input array. + * + * @template T + * @param {T[]} source - Input list. + * @returns {T[]} + */ +export function shuffle(source) { + const copy = [...source]; + for (let i = copy.length - 1; i > 0; i -= 1) { + const j = Math.floor(Math.random() * (i + 1)); + [copy[i], copy[j]] = [copy[j], copy[i]]; + } + return copy; +} + +/** + * Picks unique values from a source array. + * + * @template T + * @param {T[]} source - Source values. + * @param {number} count - Number of values to return. + * @returns {T[]} + */ +export function pickUnique(source, count) { + return shuffle(source).slice(0, count); +} + +/** + * Builds the ordered image sequence for one round. + * Primary appears exactly PRIMARY_SHOW_COUNT times. + * Each distractor appears once, and some appear a second time as level rises. + * Total sequence length is capped at MAX_POSITION_COUNT. + * + * @param {number} primarySpriteId - Target sprite id for this round. + * @param {number[]} distractorIds - Chosen distractor sprite ids. + * @param {number} lvl - Current level. + * @returns {number[]} Sequence of sprite ids. + */ +export function buildPlaybackSequence(primarySpriteId, distractorIds, lvl) { + const primaryList = Array.from({ length: PRIMARY_SHOW_COUNT }, () => primarySpriteId); + const sequence = [...primaryList, ...distractorIds]; + + const maxExtra = Math.max(0, MAX_POSITION_COUNT - sequence.length); + const extraDistractors = Math.min(lvl, distractorIds.length, maxExtra); + for (let i = 0; i < extraDistractors; i += 1) { + sequence.push(distractorIds[i]); + } + + return shuffle(sequence); +} + +/** + * Assigns a unique position to every step in the sequence. + * Positions are drawn from 0..sequence.length-1, so the number of + * selectable slots exactly equals the number of images shown. + * + * @param {number[]} sequence - Ordered sprite ids. + * @param {number} primarySpriteId - Target sprite id for this round. + * @returns {{ + * steps: Array<{ spriteId: number, positionIndex: number, isPrimary: boolean }>, + * primaryPositions: number[], + * shownPositions: number[] + * }} + */ +export function assignPositions(sequence, primarySpriteId) { + const positions = shuffle(Array.from({ length: sequence.length }, (_, i) => i)); + + const steps = sequence.map((spriteId, index) => ({ + spriteId, + positionIndex: positions[index], + isPrimary: spriteId === primarySpriteId, + })); + + const primaryPositions = steps + .filter((step) => step.isPrimary) + .map((step) => step.positionIndex); + + return { + steps, + primaryPositions, + shownPositions: positions, + }; +} + +/** + * Creates a full round definition for the current level. + * + * @param {number} lvl - Current level. + * @returns {{ + * primarySpriteId: number, + * distractorSpriteIds: number[], + * steps: Array<{ spriteId: number, positionIndex: number, isPrimary: boolean }>, + * primaryPositions: number[], + * shownPositions: number[], + * displayMs: number + * }} + */ +export function createRound(lvl) { + const spriteIds = Array.from({ length: TOTAL_SPRITES }, (_, i) => i); + const primarySpriteId = spriteIds[Math.floor(Math.random() * spriteIds.length)]; + + const distractorPool = spriteIds.filter((id) => id !== primarySpriteId); + const distractorSpriteIds = pickUnique(distractorPool, getDistractorCount(lvl)); + + const sequence = buildPlaybackSequence(primarySpriteId, distractorSpriteIds, lvl); + const positioned = assignPositions(sequence, primarySpriteId); + + return { + primarySpriteId, + distractorSpriteIds, + steps: positioned.steps, + primaryPositions: positioned.primaryPositions, + shownPositions: positioned.shownPositions, + displayMs: getDisplayDurationMs(lvl), + }; +} + +/** + * Evaluates whether player selections match this round's primary positions exactly. + * + * @param {{ primaryPositions: number[] }} round - Round metadata. + * @param {number[]} selectedPositions - Player-selected position ids. + * @returns {boolean} + */ +export function evaluateSelection(round, selectedPositions) { + if (!round || !Array.isArray(round.primaryPositions)) { + return false; + } + + if (!Array.isArray(selectedPositions) || selectedPositions.length !== PRIMARY_SHOW_COUNT) { + return false; + } + + const expected = [...round.primaryPositions].sort((a, b) => a - b); + const actual = [...selectedPositions].sort((a, b) => a - b); + return expected.every((value, index) => value === actual[index]); +} + +/** + * Records a correct round and handles level progression. + */ +export function recordCorrectRound() { + roundsPlayed += 1; + score += 1; + consecutiveCorrect += 1; + + if (consecutiveCorrect >= STREAK_TO_LEVEL_UP) { + level += 1; + consecutiveCorrect = 0; + } +} + +/** + * Records an incorrect round and resets the level-up streak. + */ +export function recordIncorrectRound() { + roundsPlayed += 1; + consecutiveCorrect = 0; +} + +/** + * Returns score. + * @returns {number} + */ +export function getScore() { + return score; +} + +/** + * Returns current level. + * @returns {number} + */ +export function getLevel() { + return level; +} + +/** + * Returns rounds played. + * @returns {number} + */ +export function getRoundsPlayed() { + return roundsPlayed; +} + +/** + * Returns current consecutive correct rounds. + * @returns {number} + */ +export function getConsecutiveCorrect() { + return consecutiveCorrect; +} + +/** + * Returns whether the game is running. + * @returns {boolean} + */ +export function isRunning() { + return running; +} diff --git a/app/games/orbit-sprite-memory/images/sprites.png b/app/games/orbit-sprite-memory/images/sprites.png new file mode 100644 index 0000000..36479c3 Binary files /dev/null and b/app/games/orbit-sprite-memory/images/sprites.png differ diff --git a/app/games/orbit-sprite-memory/images/thumbnail.png b/app/games/orbit-sprite-memory/images/thumbnail.png new file mode 100644 index 0000000..31af3c3 Binary files /dev/null and b/app/games/orbit-sprite-memory/images/thumbnail.png differ diff --git a/app/games/orbit-sprite-memory/index.js b/app/games/orbit-sprite-memory/index.js new file mode 100644 index 0000000..f510fb3 --- /dev/null +++ b/app/games/orbit-sprite-memory/index.js @@ -0,0 +1,638 @@ +/** + * index.js — Orbit Sprite Memory plugin entry point. + * + * Controls DOM interactions, timed round playback, and plugin lifecycle. + * + * @file Orbit Sprite Memory game plugin (UI/controller layer). + */ + +import * as game from './game.js'; + +/** Delay before automatically starting the next round after answer submit. */ +const NEXT_ROUND_DELAY_MS = 900; + +/** How long to show all round images after selection review. */ +const REVEAL_ALL_MS = 700; + +/** @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 _returnBtn = null; + +/** @type {HTMLElement|null} */ +let _boardEl = null; + +/** @type {HTMLElement|null} */ +let _activeSpriteEl = null; + +/** @type {HTMLElement|null} */ +let _targetPreviewEl = null; + +/** @type {HTMLElement|null} */ +let _scoreEl = null; + +/** @type {HTMLElement|null} */ +let _levelEl = null; + +/** @type {HTMLElement|null} */ +let _streakEl = null; + +/** @type {HTMLElement|null} */ +let _bestLevelEl = null; + +/** @type {HTMLElement|null} */ +let _bestScoreEl = null; + +/** @type {HTMLElement|null} */ +let _feedbackEl = null; + +/** @type {HTMLElement|null} */ +let _finalScoreEl = null; + +/** @type {HTMLElement|null} */ +let _finalLevelEl = null; + +/** @type {HTMLElement|null} */ +let _finalBestLevelEl = null; + +/** @type {HTMLElement|null} */ +let _finalBestScoreEl = null; + +/** @type {ReturnType[]} */ +let _timers = []; + +/** @type {Set} */ +let _selectedPositions = new Set(); + +/** @type {boolean} */ +let _inputEnabled = false; + +/** @type {ReturnType|null} */ +let _currentRound = null; + +/** + * Creates a short positive two-tone sound. + */ +export function playPositiveSound() { + const AudioCtx = (typeof AudioContext !== 'undefined' && AudioContext) + || (typeof window !== 'undefined' && window.webkitAudioContext) + || null; + if (!AudioCtx) return; + + try { + const ctx = new AudioCtx(); + const now = ctx.currentTime; + const osc1 = ctx.createOscillator(); + const osc2 = ctx.createOscillator(); + const gain = ctx.createGain(); + osc1.type = 'sine'; + osc2.type = 'sine'; + osc1.frequency.setValueAtTime(620, now); + osc2.frequency.setValueAtTime(820, now + 0.09); + gain.gain.setValueAtTime(0.0001, now); + gain.gain.linearRampToValueAtTime(0.18, now + 0.03); + gain.gain.exponentialRampToValueAtTime(0.0001, now + 0.25); + osc1.connect(gain); + osc2.connect(gain); + gain.connect(ctx.destination); + osc1.start(now); + osc1.stop(now + 0.12); + osc2.start(now + 0.09); + osc2.stop(now + 0.25); + osc2.onended = () => { ctx.close().catch(() => { }); }; + } catch { + // Audio feedback is optional. + } +} + +/** + * Creates a short negative buzz. + */ +export function playNegativeSound() { + const AudioCtx = (typeof AudioContext !== 'undefined' && AudioContext) + || (typeof window !== 'undefined' && window.webkitAudioContext) + || null; + if (!AudioCtx) return; + + try { + const ctx = new AudioCtx(); + const now = ctx.currentTime; + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + osc.type = 'sawtooth'; + osc.frequency.setValueAtTime(200, now); + osc.frequency.linearRampToValueAtTime(140, now + 0.22); + gain.gain.setValueAtTime(0.0001, now); + gain.gain.linearRampToValueAtTime(0.16, now + 0.02); + gain.gain.exponentialRampToValueAtTime(0.0001, now + 0.24); + osc.connect(gain); + gain.connect(ctx.destination); + osc.start(now); + osc.stop(now + 0.24); + osc.onended = () => { ctx.close().catch(() => { }); }; + } catch { + // Audio feedback is optional. + } +} + +/** + * Flashes board edge to indicate a reviewed answer. + * + * @param {'success'|'failure'} type - Flash type. + */ +export function flashBoard(type) { + if (!_boardEl) return; + _boardEl.classList.remove('osm-board--success', 'osm-board--failure'); + // Restart animation when reusing the same class. + void _boardEl.offsetWidth; + _boardEl.classList.add(type === 'success' ? 'osm-board--success' : 'osm-board--failure'); + _timers.push(setTimeout(() => { + if (_boardEl) _boardEl.classList.remove('osm-board--success', 'osm-board--failure'); + }, 320)); +} + +/** + * Restores board visuals to the default round-start appearance. + */ +export function resetBoardVisualState() { + if (!_boardEl) return; + _boardEl.classList.remove('osm-board--success', 'osm-board--failure'); +} + +/** + * Calculates CSS background-position for one sprite id from a 4x2 sprite sheet. + * + * @param {number} spriteId - Zero-based sprite id. + * @returns {string} + */ +export function getSpriteBackgroundPosition(spriteId) { + const row = Math.floor(spriteId / game.SPRITE_COLUMNS); + const col = spriteId % game.SPRITE_COLUMNS; + const colPercent = game.SPRITE_COLUMNS > 1 + ? (col / (game.SPRITE_COLUMNS - 1)) * 100 + : 0; + const rowPercent = game.SPRITE_ROWS > 1 + ? (row / (game.SPRITE_ROWS - 1)) * 100 + : 0; + return `${colPercent}% ${rowPercent}%`; +} + +/** + * Computes percentage coordinates for a point on a circular board. + * + * @param {number} positionIndex - Index around the ring. + * @param {number} totalPositions - Number of points in the ring. + * @returns {{ left: number, top: number }} + */ +export function getCircleCoordinates(positionIndex, totalPositions) { + const angle = -Math.PI / 2 + (2 * Math.PI * positionIndex) / totalPositions; + const radius = 36; + const left = 50 + Math.cos(angle) * radius; + const top = 50 + Math.sin(angle) * radius; + return { left, top }; +} + +/** + * Announces status updates for assistive tech. + * + * @param {string} message - Announced text. + */ +export function announce(message) { + if (_feedbackEl) { + _feedbackEl.textContent = message; + } +} + +/** + * Updates score, level, and streak UI values. + */ +export function updateStats() { + if (_scoreEl) _scoreEl.textContent = String(game.getScore()); + if (_levelEl) _levelEl.textContent = String(game.getLevel() + 1); + if (_streakEl) _streakEl.textContent = String(game.getConsecutiveCorrect()); +} + +/** + * Updates the displayed all-time best stats for this game. + * + * @param {{ highScore?: number, highestLevel?: number }|undefined} progressEntry + */ +export function updateBestStats(progressEntry) { + const bestScore = typeof progressEntry?.highScore === 'number' ? progressEntry.highScore : 0; + const bestLevelZeroBased = typeof progressEntry?.highestLevel === 'number' + ? progressEntry.highestLevel + : 0; + const bestLevel = bestLevelZeroBased + 1; + + if (_bestScoreEl) _bestScoreEl.textContent = String(bestScore); + if (_bestLevelEl) _bestLevelEl.textContent = String(bestLevel); + if (_finalBestScoreEl) _finalBestScoreEl.textContent = String(bestScore); + if (_finalBestLevelEl) _finalBestLevelEl.textContent = String(bestLevel); +} + +/** + * Loads saved progress and refreshes the all-time best stats UI. + * + * @returns {Promise} + */ +export async function loadBestStatsFromProgress() { + if (typeof window === 'undefined' || !window.api) { + updateBestStats(undefined); + return; + } + + try { + const loaded = await window.api.invoke('progress:load', { playerId: 'default' }); + const progressEntry = loaded?.games?.['orbit-sprite-memory']; + updateBestStats(progressEntry); + } catch { + updateBestStats(undefined); + } +} + +/** + * Clears all scheduled timers. + */ +export function clearTimers() { + _timers.forEach((timer) => clearTimeout(timer)); + _timers = []; +} + +/** + * Removes all selection circles from the board. + */ +export function clearChoiceButtons() { + if (!_boardEl) return; + const choices = _boardEl.querySelectorAll('.osm-choice-btn'); + choices.forEach((choice) => choice.remove()); +} + +/** + * Removes any reveal sprites created for post-round feedback. + */ +export function clearRevealSprites() { + if (!_boardEl) return; + const reveals = _boardEl.querySelectorAll('.osm-reveal-sprite'); + reveals.forEach((node) => node.remove()); +} + +/** + * Renders selectable circles for recall mode. + * + * @param {ReturnType} round - Round metadata. + */ +export function renderChoiceButtons(round) { + if (!_boardEl) return; + + clearChoiceButtons(); + clearRevealSprites(); + + const totalPositions = round.shownPositions.length; + round.shownPositions.forEach((positionIndex) => { + const coords = getCircleCoordinates(positionIndex, totalPositions); + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'osm-choice-btn'; + button.setAttribute('aria-label', `Position ${positionIndex + 1}`); + button.dataset.position = String(positionIndex); + button.style.left = `${coords.left}%`; + button.style.top = `${coords.top}%`; + button.addEventListener('click', () => togglePosition(positionIndex, button)); + _boardEl.appendChild(button); + }); +} + +/** + * Toggles a selected position button. + * + * @param {number} positionIndex - Selected board position. + * @param {HTMLElement} buttonEl - Position button element. + */ +export function togglePosition(positionIndex, buttonEl) { + if (!_inputEnabled) return; + + if (_selectedPositions.has(positionIndex)) { + _selectedPositions.delete(positionIndex); + buttonEl.classList.remove('osm-choice-btn--selected'); + } else if (_selectedPositions.size < game.PRIMARY_SHOW_COUNT) { + _selectedPositions.add(positionIndex); + buttonEl.classList.add('osm-choice-btn--selected'); + } + + if (_selectedPositions.size === game.PRIMARY_SHOW_COUNT) { + submitSelection(); + } +} + +/** + * Show each position's sprite briefly after scoring so the player can compare. + * + * @param {ReturnType} round - Round metadata. + */ +export function showRoundReveal(round) { + if (!_boardEl || !round) return; + + clearChoiceButtons(); + clearRevealSprites(); + if (_activeSpriteEl) _activeSpriteEl.hidden = true; + + const spriteByPosition = {}; + round.steps.forEach((step) => { + spriteByPosition[step.positionIndex] = step.spriteId; + }); + + const totalPositions = round.shownPositions.length; + round.shownPositions.forEach((positionIndex) => { + const spriteId = spriteByPosition[positionIndex]; + if (typeof spriteId !== 'number') return; + const coords = getCircleCoordinates(positionIndex, totalPositions); + const sprite = document.createElement('div'); + sprite.className = 'osm-sprite osm-reveal-sprite'; + sprite.setAttribute('aria-hidden', 'true'); + sprite.style.left = `${coords.left}%`; + sprite.style.top = `${coords.top}%`; + sprite.style.backgroundPosition = getSpriteBackgroundPosition(spriteId); + _boardEl.appendChild(sprite); + }); +} + +/** + * Positions and shows the active sprite on the board. + * + * @param {{ spriteId: number, positionIndex: number }} step - Playback step. + * @param {number} totalPositions - Total positions in this round's ring. + */ +export function showPlaybackStep(step, totalPositions) { + if (!_activeSpriteEl) return; + + const coords = getCircleCoordinates(step.positionIndex, totalPositions); + _activeSpriteEl.hidden = false; + _activeSpriteEl.style.left = `${coords.left}%`; + _activeSpriteEl.style.top = `${coords.top}%`; + _activeSpriteEl.style.backgroundPosition = getSpriteBackgroundPosition(step.spriteId); +} + +/** + * Starts the timed playback sequence for this round. + * + * @param {ReturnType} round - Round data. + */ +export function startPlayback(round) { + resetBoardVisualState(); + clearChoiceButtons(); + clearRevealSprites(); + _inputEnabled = false; + _selectedPositions = new Set(); + + const totalPositions = round.shownPositions.length; + round.steps.forEach((step, index) => { + _timers.push(setTimeout(() => { + showPlaybackStep(step, totalPositions); + }, round.displayMs * index)); + }); + + const endDelay = round.displayMs * round.steps.length; + _timers.push(setTimeout(() => { + if (_activeSpriteEl) { + _activeSpriteEl.hidden = true; + } + _inputEnabled = true; + renderChoiceButtons(round); + announce('Select the three positions where the target appeared.'); + }, endDelay)); +} + +/** + * Starts a fresh round at the current level. + */ +export function startRound() { + resetBoardVisualState(); + _currentRound = game.createRound(game.getLevel()); + + if (_targetPreviewEl) { + _targetPreviewEl.style.backgroundPosition = getSpriteBackgroundPosition( + _currentRound.primarySpriteId, + ); + } + + announce('Watch the circle. The target image appears three times.'); + startPlayback(_currentRound); +} + +/** + * Handles answer submission for the current round. + */ +export function submitSelection() { + if (!_currentRound || !_inputEnabled) return; + + const selected = [..._selectedPositions]; + const isCorrect = game.evaluateSelection(_currentRound, selected); + + _inputEnabled = false; + + if (isCorrect) { + game.recordCorrectRound(); + updateStats(); + flashBoard('success'); + playPositiveSound(); + announce('Correct. Reviewing positions before the next round.'); + } else { + game.recordIncorrectRound(); + updateStats(); + flashBoard('failure'); + playNegativeSound(); + announce('Incorrect. Reviewing positions before the next round.'); + } + + clearTimers(); + showRoundReveal(_currentRound); + + _timers.push(setTimeout(() => { + clearRevealSprites(); + _timers.push(setTimeout(() => { + startRound(); + }, NEXT_ROUND_DELAY_MS)); + }, REVEAL_ALL_MS)); +} + +/** + * Dispatches an app-level event to return to the game menu. + */ +export function returnToMainMenu() { + if (typeof window !== 'undefined') { + window.dispatchEvent(new CustomEvent('bsx:return-to-main-menu')); + } +} + +/** + * Shows the end panel with final score information. + * + * @param {{ score: number, level: number }} result - End-game result. + */ +export 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); +} + +/** Human-readable plugin name. */ +const name = 'Orbit Sprite Memory'; + +/** + * Initializes the plugin and wires UI events. + * + * @param {HTMLElement|null} gameContainer - Injected game container. + */ +function init(gameContainer) { + _container = gameContainer; + game.initGame(); + clearTimers(); + + if (!_container) return; + + _instructionsEl = _container.querySelector('#osm-instructions'); + _gameAreaEl = _container.querySelector('#osm-game-area'); + _endPanelEl = _container.querySelector('#osm-end-panel'); + _startBtn = _container.querySelector('#osm-start-btn'); + _stopBtn = _container.querySelector('#osm-stop-btn'); + _playAgainBtn = _container.querySelector('#osm-play-again-btn'); + _returnBtn = _container.querySelector('#osm-return-btn'); + _boardEl = _container.querySelector('#osm-board'); + _activeSpriteEl = _container.querySelector('#osm-active-sprite'); + _targetPreviewEl = _container.querySelector('#osm-target-preview'); + _scoreEl = _container.querySelector('#osm-score'); + _levelEl = _container.querySelector('#osm-level'); + _streakEl = _container.querySelector('#osm-streak'); + _bestLevelEl = _container.querySelector('#osm-best-level'); + _bestScoreEl = _container.querySelector('#osm-best-score'); + _feedbackEl = _container.querySelector('#osm-feedback'); + _finalScoreEl = _container.querySelector('#osm-final-score'); + _finalLevelEl = _container.querySelector('#osm-final-level'); + _finalBestLevelEl = _container.querySelector('#osm-final-best-level'); + _finalBestScoreEl = _container.querySelector('#osm-final-best-score'); + + if (_startBtn) _startBtn.addEventListener('click', () => start()); + if (_stopBtn) _stopBtn.addEventListener('click', () => stop()); + if (_playAgainBtn) { + _playAgainBtn.addEventListener('click', () => { + reset(); + start(); + }); + } + if (_returnBtn) _returnBtn.addEventListener('click', () => returnToMainMenu()); + + loadBestStatsFromProgress(); + updateStats(); +} + +/** + * Starts gameplay and first round playback. + */ +function start() { + game.startGame(); + resetBoardVisualState(); + + if (_instructionsEl) _instructionsEl.hidden = true; + if (_endPanelEl) _endPanelEl.hidden = true; + if (_gameAreaEl) _gameAreaEl.hidden = false; + + startRound(); +} + +/** + * Stops gameplay, saves progress, and displays end panel. + * + * @returns {{ score: number, level: number, roundsPlayed: number, duration: number }} + */ +function stop() { + clearTimers(); + resetBoardVisualState(); + const result = game.stopGame(); + + (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 { + existing = { playerId: 'default', games: {} }; + } + + const previous = (existing.games && existing.games['orbit-sprite-memory']) || {}; + const payload = { + ...existing, + games: { + ...existing.games, + 'orbit-sprite-memory': { + highScore: Math.max(result.score, previous.highScore || 0), + sessionsPlayed: (previous.sessionsPlayed || 0) + 1, + lastPlayed: new Date().toISOString(), + highestLevel: Math.max(result.level, previous.highestLevel || 0), + }, + }, + }; + + await window.api.invoke('progress:save', { playerId: 'default', data: payload }); + updateBestStats(payload.games['orbit-sprite-memory']); + } catch { + // Progress saving is non-blocking for gameplay flow. + } + } + })(); + + showEndPanel(result); + return result; +} + +/** + * Resets UI and logic state to pre-start mode. + */ +function reset() { + clearTimers(); + resetBoardVisualState(); + game.initGame(); + + _currentRound = null; + _inputEnabled = false; + _selectedPositions = new Set(); + + if (_activeSpriteEl) _activeSpriteEl.hidden = true; + if (_instructionsEl) _instructionsEl.hidden = false; + if (_gameAreaEl) _gameAreaEl.hidden = true; + if (_endPanelEl) _endPanelEl.hidden = true; + if (_feedbackEl) _feedbackEl.textContent = ''; + + clearChoiceButtons(); + clearRevealSprites(); + loadBestStatsFromProgress(); + updateStats(); +} + +export default { + name, + init, + start, + stop, + reset, +}; diff --git a/app/games/orbit-sprite-memory/interface.html b/app/games/orbit-sprite-memory/interface.html new file mode 100644 index 0000000..4f3e0bf --- /dev/null +++ b/app/games/orbit-sprite-memory/interface.html @@ -0,0 +1,60 @@ +
+

Orbit Sprite Memory

+ +
+

How to Play

+

+ Watch the images around the circle. One image is the target for this round, + and it appears exactly three times. +

+
    +
  • The target image is shown before each round starts.
  • +
  • Distractor images appear no more than two times each.
  • +
  • After playback, select the three circles where the target appeared.
  • +
  • The number of circles to choose from grows as the level increases.
  • +
  • Get three correct rounds in a row to level up.
  • +
  • Higher levels show more distractors and reduce display time.
  • +
+ +
+ + + + +
diff --git a/app/games/orbit-sprite-memory/manifest.json b/app/games/orbit-sprite-memory/manifest.json new file mode 100644 index 0000000..490b621 --- /dev/null +++ b/app/games/orbit-sprite-memory/manifest.json @@ -0,0 +1,9 @@ +{ + "id": "orbit-sprite-memory", + "name": "Orbit Sprite Memory", + "description": "Track where the target sprite appears around the circle, then pick its three positions.", + "version": "0.1.0", + "entryPoint": "index.js", + "thumbnail": "images/thumbnail.png", + "author": "BrainSpeed Exercises" +} diff --git a/app/games/orbit-sprite-memory/style.css b/app/games/orbit-sprite-memory/style.css new file mode 100644 index 0000000..8c5bcd5 --- /dev/null +++ b/app/games/orbit-sprite-memory/style.css @@ -0,0 +1,140 @@ +.orbit-memory { + --osm-bg: #f6f2e9; + --osm-text: #1e2a33; + --osm-accent: #cc6e2f; + --osm-accent-dark: #9f4d1d; + --osm-border: #ccd8df; + --osm-board: #ffffff; + --osm-focus: #005fcc; + color: var(--osm-text); + background: radial-gradient(circle at 20% 0%, #fff9ef 0%, var(--osm-bg) 55%, #ece4d7 100%); + border: 1px solid var(--osm-border); + border-radius: 18px; + padding: 1rem; +} + +.osm-panel { + background: rgba(255, 255, 255, 0.7); + border: 1px solid var(--osm-border); + border-radius: 12px; + padding: 1rem; +} + +.osm-stats { + display: flex; + flex-wrap: wrap; + gap: 1rem; + margin: 0.75rem 0; +} + +.osm-target-wrap { + display: flex; + align-items: center; + gap: 0.75rem; + margin-bottom: 0.75rem; +} + +.osm-board { + position: relative; + width: min(94vw, 600px); + height: min(94vw, 600px); + margin: 0 auto; + border: 2px solid var(--osm-border); + border-radius: 50%; + background: #ffffff; +} + +.osm-sprite { + width: 96px; + height: 96px; + background-image: url('./images/sprites.png'); + background-repeat: no-repeat; + background-size: 400% 200%; +} + +.osm-active-sprite { + position: absolute; + transform: translate(-50%, -50%); +} + +.osm-reveal-sprite { + position: absolute; + transform: translate(-50%, -50%); +} + +.osm-choice-btn { + position: absolute; + transform: translate(-50%, -50%); + width: 40px; + height: 40px; + border-radius: 50%; + border: 2px solid #4d6b7a; + background: #ffffff; + cursor: pointer; +} + +.osm-choice-btn:hover { + border-color: var(--osm-accent-dark); +} + +.osm-choice-btn:focus-visible, +.osm-btn:focus-visible { + outline: 3px solid var(--osm-focus); + outline-offset: 2px; +} + +.osm-choice-btn--selected { + background: var(--osm-accent); + border-color: var(--osm-accent-dark); +} + +.osm-controls, +.osm-end-actions { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + margin-top: 1rem; +} + +.osm-btn { + border: 1px solid transparent; + border-radius: 999px; + padding: 0.55rem 1rem; + font-weight: 600; + cursor: pointer; +} + +.osm-btn--primary { + background: var(--osm-accent); + color: #fff; +} + +.osm-btn--primary:hover { + background: var(--osm-accent-dark); +} + +.osm-btn--secondary { + background: #eff4f7; + border-color: var(--osm-border); + color: var(--osm-text); +} + +.osm-board--success { + box-shadow: 0 0 0 6px rgba(43, 166, 91, 0.55); +} + +.osm-board--failure { + box-shadow: 0 0 0 6px rgba(198, 58, 58, 0.55); +} + +@media (max-width: 520px) { + .osm-sprite { + width: 78px; + height: 78px; + } + + .osm-choice-btn { + width: 34px; + height: 34px; + } +} diff --git a/app/games/orbit-sprite-memory/tests/game.test.js b/app/games/orbit-sprite-memory/tests/game.test.js new file mode 100644 index 0000000..4c31dba --- /dev/null +++ b/app/games/orbit-sprite-memory/tests/game.test.js @@ -0,0 +1,227 @@ +/** @jest-environment node */ +import { + describe, + test, + expect, + beforeEach, +} from '@jest/globals'; + +import { + TOTAL_SPRITES, + SPRITE_COLUMNS, + SPRITE_ROWS, + MAX_POSITION_COUNT, + PRIMARY_SHOW_COUNT, + MAX_DISTRACTOR_SHOWS, + STREAK_TO_LEVEL_UP, + BASE_DISPLAY_MS, + DISPLAY_DECREMENT_MS, + MIN_DISPLAY_MS, + BASE_DISTRACTOR_COUNT, + initGame, + startGame, + stopGame, + getDisplayDurationMs, + getDistractorCount, + shuffle, + pickUnique, + buildPlaybackSequence, + assignPositions, + createRound, + evaluateSelection, + recordCorrectRound, + recordIncorrectRound, + getScore, + getLevel, + getRoundsPlayed, + getConsecutiveCorrect, + isRunning, +} from '../game.js'; + +beforeEach(() => { + initGame(); +}); + +describe('constants', () => { + test('sprite sheet constants reflect a 4x2 grid', () => { + expect(SPRITE_COLUMNS).toBe(4); + expect(SPRITE_ROWS).toBe(2); + expect(TOTAL_SPRITES).toBe(8); + }); + + test('round constraints constants are stable', () => { + expect(MAX_POSITION_COUNT).toBeGreaterThanOrEqual(8); + expect(PRIMARY_SHOW_COUNT).toBe(3); + expect(MAX_DISTRACTOR_SHOWS).toBe(2); + expect(STREAK_TO_LEVEL_UP).toBe(3); + expect(BASE_DISTRACTOR_COUNT).toBe(2); + }); + + test('MAX_POSITION_COUNT caps the sequence length', () => { + expect(MAX_POSITION_COUNT).toBeLessThanOrEqual(16); + }); + + test('timing constants are valid', () => { + expect(BASE_DISPLAY_MS).toBeGreaterThan(MIN_DISPLAY_MS); + expect(DISPLAY_DECREMENT_MS).toBeGreaterThan(0); + }); +}); + +describe('init/start/stop lifecycle', () => { + test('initGame resets all counters and flags', () => { + recordCorrectRound(); + startGame(); + initGame(); + + expect(getScore()).toBe(0); + expect(getLevel()).toBe(0); + expect(getRoundsPlayed()).toBe(0); + expect(getConsecutiveCorrect()).toBe(0); + expect(isRunning()).toBe(false); + }); + + test('startGame sets running true', () => { + startGame(); + expect(isRunning()).toBe(true); + }); + + test('startGame throws when already running', () => { + startGame(); + expect(() => startGame()).toThrow('already running'); + }); + + test('stopGame returns summary and clears running', () => { + startGame(); + const result = stopGame(); + expect(result).toMatchObject({ + score: 0, + level: 0, + roundsPlayed: 0, + }); + expect(typeof result.duration).toBe('number'); + expect(isRunning()).toBe(false); + }); + + test('stopGame throws when not running', () => { + expect(() => stopGame()).toThrow('not running'); + }); +}); + +describe('difficulty helpers', () => { + test('display duration decreases by level and respects minimum', () => { + expect(getDisplayDurationMs(0)).toBe(BASE_DISPLAY_MS); + expect(getDisplayDurationMs(1)).toBe(BASE_DISPLAY_MS - DISPLAY_DECREMENT_MS); + expect(getDisplayDurationMs(1000)).toBe(MIN_DISPLAY_MS); + }); + + test('distractor count rises with level and caps at TOTAL_SPRITES - 1', () => { + expect(getDistractorCount(0)).toBe(BASE_DISTRACTOR_COUNT); + expect(getDistractorCount(1)).toBe(BASE_DISTRACTOR_COUNT + 1); + expect(getDistractorCount(1000)).toBe(TOTAL_SPRITES - 1); + }); +}); + +describe('array helpers', () => { + test('shuffle returns same items and keeps array length', () => { + const input = [1, 2, 3, 4, 5]; + const output = shuffle(input); + + expect(output).toHaveLength(input.length); + expect([...output].sort((a, b) => a - b)).toEqual(input); + }); + + test('pickUnique returns requested count without duplicates', () => { + const values = [0, 1, 2, 3, 4, 5]; + const picked = pickUnique(values, 3); + + expect(picked).toHaveLength(3); + expect(new Set(picked).size).toBe(3); + picked.forEach((value) => expect(values).toContain(value)); + }); +}); + +describe('round generation', () => { + test('buildPlaybackSequence includes primary exactly three times', () => { + const sequence = buildPlaybackSequence(7, [1, 2, 3], 2); + const primaryCount = sequence.filter((id) => id === 7).length; + const byDistractor = sequence.filter((id) => id !== 7); + + expect(primaryCount).toBe(PRIMARY_SHOW_COUNT); + expect(byDistractor.length).toBeGreaterThanOrEqual(3); + }); + + test('buildPlaybackSequence caps sequence at MAX_POSITION_COUNT', () => { + // Level 100 would add unlimited extras without the cap. + const allDistractors = Array.from({ length: TOTAL_SPRITES - 1 }, (_, i) => i + 1); + const sequence = buildPlaybackSequence(0, allDistractors, 100); + expect(sequence.length).toBeLessThanOrEqual(MAX_POSITION_COUNT); + expect(sequence.filter((id) => id === 0).length).toBe(PRIMARY_SHOW_COUNT); + }); + + test('assignPositions gives each step a unique position', () => { + const sequence = [3, 3, 3, 1, 2, 4]; + const assigned = assignPositions(sequence, 3); + + expect(assigned.shownPositions).toHaveLength(sequence.length); + expect(new Set(assigned.shownPositions).size).toBe(sequence.length); + + const allIndexes = assigned.steps.map((s) => s.positionIndex); + expect(new Set(allIndexes).size).toBe(sequence.length); + + const primarySteps = assigned.steps.filter((step) => step.isPrimary); + expect(primarySteps).toHaveLength(PRIMARY_SHOW_COUNT); + primarySteps.forEach((step) => { + expect(assigned.primaryPositions).toContain(step.positionIndex); + }); + }); + + test('createRound returns a complete round payload', () => { + const round = createRound(1); + + expect(typeof round.primarySpriteId).toBe('number'); + expect(round.primarySpriteId).toBeGreaterThanOrEqual(0); + expect(round.primarySpriteId).toBeLessThan(TOTAL_SPRITES); + expect(round.displayMs).toBe(getDisplayDurationMs(1)); + expect(round.primaryPositions).toHaveLength(PRIMARY_SHOW_COUNT); + expect(round.steps.length).toBeGreaterThan(round.distractorSpriteIds.length); + }); +}); + +describe('selection and scoring', () => { + test('evaluateSelection is true for exact position match', () => { + const round = { primaryPositions: [1, 3, 5] }; + expect(evaluateSelection(round, [5, 1, 3])).toBe(true); + }); + + test('evaluateSelection is false for wrong, malformed, or missing data', () => { + const round = { primaryPositions: [1, 3, 5] }; + expect(evaluateSelection(round, [1, 3, 4])).toBe(false); + expect(evaluateSelection(round, [1, 3])).toBe(false); + expect(evaluateSelection(null, [1, 3, 5])).toBe(false); + }); + + test('recordCorrectRound increments score and rounds', () => { + recordCorrectRound(); + expect(getScore()).toBe(1); + expect(getRoundsPlayed()).toBe(1); + expect(getConsecutiveCorrect()).toBe(1); + }); + + test('recordCorrectRound levels up after three consecutive rounds', () => { + recordCorrectRound(); + recordCorrectRound(); + recordCorrectRound(); + + expect(getLevel()).toBe(1); + expect(getConsecutiveCorrect()).toBe(0); + }); + + test('recordIncorrectRound resets streak and increments rounds', () => { + recordCorrectRound(); + expect(getConsecutiveCorrect()).toBe(1); + + recordIncorrectRound(); + expect(getConsecutiveCorrect()).toBe(0); + expect(getRoundsPlayed()).toBe(2); + }); +}); diff --git a/app/games/orbit-sprite-memory/tests/index.test.js b/app/games/orbit-sprite-memory/tests/index.test.js new file mode 100644 index 0000000..9d7828d --- /dev/null +++ b/app/games/orbit-sprite-memory/tests/index.test.js @@ -0,0 +1,365 @@ +import { + jest, + describe, + test, + expect, + beforeEach, + afterEach, +} from '@jest/globals'; + +jest.unstable_mockModule('../game.js', () => ({ + TOTAL_SPRITES: 8, + SPRITE_COLUMNS: 4, + SPRITE_ROWS: 2, + MAX_POSITION_COUNT: 12, + PRIMARY_SHOW_COUNT: 3, + MAX_DISTRACTOR_SHOWS: 2, + STREAK_TO_LEVEL_UP: 3, + BASE_DISPLAY_MS: 1100, + DISPLAY_DECREMENT_MS: 90, + MIN_DISPLAY_MS: 250, + BASE_DISTRACTOR_COUNT: 2, + initGame: jest.fn(), + startGame: jest.fn(), + stopGame: jest.fn(() => ({ + score: 4, + level: 1, + roundsPlayed: 6, + duration: 5000, + })), + getDisplayDurationMs: jest.fn(() => 900), + getDistractorCount: jest.fn(() => 3), + shuffle: jest.fn((value) => value), + pickUnique: jest.fn((values, count) => values.slice(0, count)), + buildPlaybackSequence: jest.fn(), + assignPositions: jest.fn(), + createRound: jest.fn(() => ({ + primarySpriteId: 2, + distractorSpriteIds: [1, 3], + steps: [ + { spriteId: 2, positionIndex: 0, isPrimary: true }, + { spriteId: 1, positionIndex: 2, isPrimary: false }, + { spriteId: 2, positionIndex: 3, isPrimary: true }, + { spriteId: 3, positionIndex: 5, isPrimary: false }, + { spriteId: 2, positionIndex: 7, isPrimary: true }, + ], + primaryPositions: [0, 3, 7], + shownPositions: [0, 2, 3, 5, 7], + displayMs: 40, + })), + evaluateSelection: jest.fn((round, selected) => { + const values = [...selected].sort((a, b) => a - b); + return JSON.stringify(values) === JSON.stringify([0, 3, 7]); + }), + recordCorrectRound: jest.fn(), + recordIncorrectRound: jest.fn(), + getScore: jest.fn(() => 4), + getLevel: jest.fn(() => 1), + getRoundsPlayed: jest.fn(() => 6), + getConsecutiveCorrect: jest.fn(() => 2), + isRunning: jest.fn(() => false), +})); + +const pluginModule = await import('../index.js'); +const plugin = pluginModule.default; +const gameMock = await import('../game.js'); + +const { + getSpriteBackgroundPosition, + getCircleCoordinates, + announce, + updateStats, + clearTimers, + clearChoiceButtons, + renderChoiceButtons, + togglePosition, + showPlaybackStep, + startPlayback, + startRound, + submitSelection, + returnToMainMenu, + showEndPanel, +} = pluginModule; + +function buildContainer() { + const el = document.createElement('div'); + el.innerHTML = ` +
+ + + + + + +
+ +
+
+
+ 0 + 1 + 0 + 0 + 1 + `; + document.body.appendChild(el); + return el; +} + +describe('exported helper utilities', () => { + beforeEach(() => { + jest.useFakeTimers(); + plugin.init(buildContainer()); + }); + + afterEach(() => { + jest.useRealTimers(); + document.body.innerHTML = ''; + }); + + test('computes sprite sheet background positions', () => { + expect(getSpriteBackgroundPosition(0)).toBe('0% 0%'); + expect(getSpriteBackgroundPosition(4)).toBe('0% 100%'); + expect(getSpriteBackgroundPosition(5)).toBe('33.33333333333333% 100%'); + }); + + test('computes circular board coordinates', () => { + const coords = getCircleCoordinates(0, 8); + expect(coords.left).toBeCloseTo(50); + expect(coords.top).toBeCloseTo(14); + }); + + test('announces status text and updates stat labels', () => { + announce('hello'); + expect(document.querySelector('#osm-feedback').textContent).toBe('hello'); + + updateStats(); + expect(document.querySelector('#osm-score').textContent).toBe('4'); + expect(document.querySelector('#osm-level').textContent).toBe('2'); + expect(document.querySelector('#osm-streak').textContent).toBe('2'); + }); + + test('showPlaybackStep positions and reveals active sprite', () => { + showPlaybackStep({ spriteId: 2, positionIndex: 3 }, 5); + const sprite = document.querySelector('#osm-active-sprite'); + expect(sprite.hidden).toBe(false); + expect(sprite.style.backgroundPosition).toContain('%'); + }); + + test('clearTimers can run safely with pending timers', () => { + const timer = setTimeout(() => { }, 1000); + expect(timer).toBeTruthy(); + clearTimers(); + }); + + test('clearChoiceButtons removes existing choice nodes', () => { + const board = document.querySelector('#osm-board'); + const btn = document.createElement('button'); + btn.className = 'osm-choice-btn'; + board.appendChild(btn); + + clearChoiceButtons(); + expect(document.querySelectorAll('.osm-choice-btn')).toHaveLength(0); + }); + + test('renderChoiceButtons adds selectable nodes', () => { + renderChoiceButtons({ shownPositions: [1, 2, 5] }); + expect(document.querySelectorAll('.osm-choice-btn')).toHaveLength(3); + }); + + test('togglePosition marks and unmarks button once input is enabled', () => { + plugin.start(); + jest.advanceTimersByTime(400); + + const btn = document.querySelector('.osm-choice-btn'); + const position = Number(btn.dataset.position); + togglePosition(position, btn); + expect(btn.classList.contains('osm-choice-btn--selected')).toBe(true); + + togglePosition(position, btn); + expect(btn.classList.contains('osm-choice-btn--selected')).toBe(false); + }); + + test('startPlayback schedules round playback and enables choices at end', () => { + startPlayback(gameMock.createRound()); + jest.advanceTimersByTime(200); + + expect(document.querySelector('#osm-active-sprite').hidden).toBe(true); + expect(document.querySelectorAll('.osm-choice-btn').length).toBeGreaterThan(0); + }); + + test('startRound requests round from game logic and sets target preview', () => { + startRound(); + expect(gameMock.createRound).toHaveBeenCalled(); + expect(document.querySelector('#osm-target-preview').style.backgroundPosition).toContain('%'); + }); + + test('auto review records correct answers on third selection', () => { + plugin.start(); + jest.advanceTimersByTime(400); + + const buttons = document.querySelectorAll('.osm-choice-btn'); + buttons[0].click(); + buttons[2].click(); + buttons[4].click(); + + expect(gameMock.recordCorrectRound).toHaveBeenCalled(); + }); + + test('auto review records incorrect answers on third selection', () => { + plugin.start(); + jest.advanceTimersByTime(400); + + const buttons = document.querySelectorAll('.osm-choice-btn'); + buttons[1].click(); + buttons[2].click(); + buttons[3].click(); + + expect(gameMock.recordIncorrectRound).toHaveBeenCalled(); + }); + + test('manual submitSelection still works when invoked directly', () => { + plugin.start(); + jest.advanceTimersByTime(400); + + const buttons = document.querySelectorAll('.osm-choice-btn'); + buttons[1].click(); + buttons[2].click(); + buttons[3].click(); + submitSelection(); + + expect(gameMock.recordIncorrectRound).toHaveBeenCalled(); + }); + + test('returnToMainMenu dispatches menu event', () => { + let fired = false; + window.addEventListener('bsx:return-to-main-menu', () => { + fired = true; + }, { once: true }); + + returnToMainMenu(); + expect(fired).toBe(true); + }); + + test('showEndPanel reveals summary values', () => { + showEndPanel({ score: 9, level: 3 }); + expect(document.querySelector('#osm-end-panel').hidden).toBe(false); + expect(document.querySelector('#osm-final-score').textContent).toBe('9'); + expect(document.querySelector('#osm-final-level').textContent).toBe('4'); + }); +}); + +describe('plugin contract and lifecycle', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + document.body.innerHTML = ''; + }); + + test('exposes plugin contract', () => { + expect(typeof plugin.name).toBe('string'); + expect(typeof plugin.init).toBe('function'); + expect(typeof plugin.start).toBe('function'); + expect(typeof plugin.stop).toBe('function'); + expect(typeof plugin.reset).toBe('function'); + }); + + test('init accepts null container safely', () => { + expect(() => plugin.init(null)).not.toThrow(); + }); + + test('start toggles panels and starts game logic', () => { + const container = buildContainer(); + plugin.init(container); + plugin.start(); + + expect(gameMock.startGame).toHaveBeenCalled(); + expect(container.querySelector('#osm-game-area').hidden).toBe(false); + expect(container.querySelector('#osm-instructions').hidden).toBe(true); + }); + + test('stop returns result and shows end panel', () => { + const container = buildContainer(); + plugin.init(container); + plugin.start(); + + const result = plugin.stop(); + expect(result.score).toBe(4); + expect(container.querySelector('#osm-end-panel').hidden).toBe(false); + }); + + test('stop saves progress via window.api invoke', async () => { + const container = buildContainer(); + plugin.init(container); + plugin.start(); + + const mockApi = { + invoke: jest.fn() + .mockResolvedValueOnce({ playerId: 'default', games: {} }) + .mockResolvedValueOnce(undefined), + }; + globalThis.window = globalThis.window || {}; + const oldApi = 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', + }), + ); + + globalThis.window.api = oldApi; + }); + + test('reset returns to pre-game state', () => { + const container = buildContainer(); + plugin.init(container); + plugin.start(); + + plugin.reset(); + expect(container.querySelector('#osm-game-area').hidden).toBe(true); + expect(container.querySelector('#osm-instructions').hidden).toBe(false); + }); + + test('play-again and return buttons invoke handlers', () => { + const container = buildContainer(); + plugin.init(container); + plugin.start(); + plugin.stop(); + + let fired = false; + window.addEventListener('bsx:return-to-main-menu', () => { + fired = true; + }, { once: true }); + + container.querySelector('#osm-play-again-btn').click(); + expect(container.querySelector('#osm-game-area').hidden).toBe(false); + + container.querySelector('#osm-return-btn').click(); + expect(fired).toBe(true); + }); + + test('stop button and auto-review flow are wired', () => { + const container = buildContainer(); + plugin.init(container); + plugin.start(); + + jest.advanceTimersByTime(400); + const buttons = container.querySelectorAll('.osm-choice-btn'); + buttons[0].click(); + buttons[2].click(); + buttons[4].click(); + expect(gameMock.recordCorrectRound).toHaveBeenCalled(); + + container.querySelector('#osm-stop-btn').click(); + expect(container.querySelector('#osm-end-panel').hidden).toBe(false); + }); +});