diff --git a/app/games/otter-stop/game.js b/app/games/otter-stop/game.js index 9e5b54b..825a4fb 100644 --- a/app/games/otter-stop/game.js +++ b/app/games/otter-stop/game.js @@ -120,6 +120,13 @@ let consecutiveWrong = 0; */ let speedHistory = []; +/** + * Response times (ms) for go trials where the player actually responded. + * Used to compute the average response time displayed in the stats bar. + * @type {number[]} + */ +let goResponseTimes = []; + /** * Whether the next trial must be a go image (forced after any wrong outcome). * Ensures the player gets a fair chance to respond correctly before facing @@ -167,6 +174,7 @@ export function initGame() { consecutiveWrong = 0; forceGoNext = false; speedHistory = []; + goResponseTimes = []; sequencePosition = 0; currentSequenceLength = generateSequenceLength(); } @@ -465,3 +473,27 @@ export function isRunning() { export function getSpeedHistory() { return [...speedHistory]; } + +/** + * Record a response time (ms) for a go trial where the player responded. + * Only go trials where the player actually presses Space should be counted. + * + * @param {number} ms - Time (ms) between when the go stimulus appeared and + * when the player responded. + */ +export function recordGoResponseTime(ms) { + goResponseTimes.push(ms); +} + +/** + * Return the running average of all recorded go-trial response times, rounded + * to the nearest millisecond. Returns null when no go responses have been + * recorded yet (i.e. at the start of a game). + * + * @returns {number|null} + */ +export function getAverageResponseMs() { + if (goResponseTimes.length === 0) return null; + const total = goResponseTimes.reduce((sum, t) => sum + t, 0); + return Math.round(total / goResponseTimes.length); +} diff --git a/app/games/otter-stop/index.js b/app/games/otter-stop/index.js index 74f23aa..9671e27 100644 --- a/app/games/otter-stop/index.js +++ b/app/games/otter-stop/index.js @@ -89,6 +89,9 @@ let _trendEmptyEl = null; /** @type {HTMLElement|null} */ let _trendLatestEl = null; +/** @type {HTMLElement|null} */ +let _avgResponseEl = null; + /** @type {HTMLElement|null} */ let _finalScoreEl = null; @@ -115,6 +118,12 @@ let _currentIsNoGo = false; /** Whether Space was pressed during the current trial window. */ let _spacePressedThisTrial = false; +/** + * Timestamp (ms) when the current go stimulus was shown. + * Null when no trial is active or the current stimulus is a no-go image. + */ +let _goTrialStartMs = null; + /** setTimeout handle for the trial display window. */ let _trialTimer = null; @@ -276,6 +285,12 @@ export function showEndPanel(result) { export function endTrial() { if (!game.isRunning()) return; + // Record response time for go trials where the player actually responded. + if (!_currentIsNoGo && _spacePressedThisTrial && _goTrialStartMs !== null) { + game.recordGoResponseTime(Date.now() - _goTrialStartMs); + } + _goTrialStartMs = null; + const outcome = game.recordResponse(_currentIsNoGo, _spacePressedThisTrial); updateStats(); updateTrendChart(); @@ -284,6 +299,13 @@ export function endTrial() { _currentImageKey = null; _currentIsNoGo = false; + // Update the average response stat only at the end of a complete sequence + // (i.e., after the no-go stimulus), so it reflects the whole go-run. + if (wasNoGo && _avgResponseEl) { + const avgMs = game.getAverageResponseMs(); + _avgResponseEl.textContent = avgMs !== null ? avgMs : '--'; + } + hideImage(); // Show feedback for no-go trials and for go images the player missed. @@ -319,6 +341,9 @@ export function beginTrial() { _currentImageKey = imageKey; _currentIsNoGo = isNoGo; + // Record when the go stimulus appears so we can measure reaction time. + _goTrialStartMs = isNoGo ? null : Date.now(); + showImage(imageKey); const intervalMs = game.getCurrentIntervalMs(); @@ -420,6 +445,7 @@ function init(container) { _trendLineEl = container.querySelector('#os-trend-line'); _trendEmptyEl = container.querySelector('#os-trend-empty'); _trendLatestEl = container.querySelector('#os-trend-latest'); + _avgResponseEl = container.querySelector('#os-avg-response'); _finalScoreEl = container.querySelector('#os-final-score'); _finalBestEl = container.querySelector('#os-final-best'); _finalNogoEl = container.querySelector('#os-final-nogo'); diff --git a/app/games/otter-stop/interface.html b/app/games/otter-stop/interface.html index 21e0865..bdc4526 100644 --- a/app/games/otter-stop/interface.html +++ b/app/games/otter-stop/interface.html @@ -51,6 +51,9 @@

How to Play

Interval: 1500 ms + + Avg Resp: -- ms + Session: 00:00 diff --git a/app/games/otter-stop/style.css b/app/games/otter-stop/style.css index 335c26e..321db33 100644 --- a/app/games/otter-stop/style.css +++ b/app/games/otter-stop/style.css @@ -113,25 +113,27 @@ .os-feedback { position: absolute; inset: 0; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 0.5rem; background: rgba(255, 255, 255, 0.93); border-radius: 12px; z-index: 2; } .os-feedback__img { - flex: 1; - min-height: 0; + position: absolute; + inset: 0; width: 100%; + height: 100%; object-fit: contain; } .os-feedback__text { + position: absolute; + bottom: 0.5rem; + left: 0; + right: 0; + text-align: center; margin: 0; + padding: 0.1rem 0.5rem; font-size: 1.1rem; font-weight: 700; color: var(--text-heading); diff --git a/app/games/otter-stop/tests/game.test.js b/app/games/otter-stop/tests/game.test.js index 17561df..0989df1 100644 --- a/app/games/otter-stop/tests/game.test.js +++ b/app/games/otter-stop/tests/game.test.js @@ -26,6 +26,8 @@ import { GO_KEYS, setGoKeys, getSpeedHistory, + recordGoResponseTime, + getAverageResponseMs, } from '../game.js'; /** Default go keys used by the test suite (matches built-in defaults). */ @@ -77,7 +79,11 @@ describe('setGoKeys()', () => { it('pickNextImage() picks from the new keys after setGoKeys()', () => { setGoKeys(['custom.png']); - const spy = jest.spyOn(Math, 'random').mockReturnValue(0); + // Use a non-zero Math.random value during initGame() to guarantee a + // non-zero currentSequenceLength, then switch to 0 for the idx pick. + const spy = jest.spyOn(Math, 'random').mockReturnValue(0.5); + initGame(); // currentSequenceLength = Math.floor(0.5 * 6) = 3 + spy.mockReturnValue(0); // idx = Math.floor(0 * 1) = 0 → GO_KEYS[0] = 'custom.png' const { imageKey } = pickNextImage(); spy.mockRestore(); expect(imageKey).toBe('custom.png'); @@ -695,6 +701,51 @@ describe('getSpeedHistory()', () => { }); }); +// ── recordGoResponseTime / getAverageResponseMs ─────────────────────────────── + +describe('getAverageResponseMs()', () => { + it('returns null when no go response times have been recorded', () => { + expect(getAverageResponseMs()).toBeNull(); + }); + + it('returns the single recorded value when only one response has been recorded', () => { + recordGoResponseTime(400); + expect(getAverageResponseMs()).toBe(400); + }); + + it('returns the rounded average of multiple recorded times', () => { + recordGoResponseTime(300); + recordGoResponseTime(500); + expect(getAverageResponseMs()).toBe(400); + }); + + it('rounds to the nearest millisecond', () => { + recordGoResponseTime(300); + recordGoResponseTime(301); + expect(getAverageResponseMs()).toBe(301); // Math.round(601 / 2) = 301 + }); + + it('resets to null after initGame()', () => { + recordGoResponseTime(350); + initGame(); + expect(getAverageResponseMs()).toBeNull(); + }); +}); + +describe('recordGoResponseTime()', () => { + it('accumulates response times so each additional entry changes the average', () => { + recordGoResponseTime(200); + expect(getAverageResponseMs()).toBe(200); + recordGoResponseTime(400); + expect(getAverageResponseMs()).toBe(300); + }); + + it('accepts any non-negative value including 0', () => { + recordGoResponseTime(0); + expect(getAverageResponseMs()).toBe(0); + }); +}); + // ── getMaxSequenceLength ────────────────────────────────────────────────────── describe('getMaxSequenceLength()', () => { diff --git a/app/games/otter-stop/tests/index.test.js b/app/games/otter-stop/tests/index.test.js index 2c5613e..2acbc7e 100644 --- a/app/games/otter-stop/tests/index.test.js +++ b/app/games/otter-stop/tests/index.test.js @@ -43,6 +43,8 @@ jest.unstable_mockModule('../game.js', () => ({ isRunning: jest.fn(() => true), setGoKeys: jest.fn(), getSpeedHistory: jest.fn(() => []), + getAverageResponseMs: jest.fn(() => null), + recordGoResponseTime: jest.fn(), IMAGE_KEYS: ['go-1.png', 'go-2.png', 'go-3.png', 'no-go'], NO_GO_KEY: 'no-go', })); @@ -122,6 +124,7 @@ function buildContainer() { 0 0 1500 + -- 0 0 0 @@ -652,6 +655,46 @@ describe('endTrial()', () => { const fb = container.querySelector('#os-feedback'); expect(fb.hidden).toBe(true); }); + + it('does not update avg response stat after a go trial', () => { + const container = buildContainer(); + plugin.init(container); + plugin.start(); + gameMock.getAverageResponseMs.mockReturnValue(250); + gameMock.pickNextImage.mockReturnValueOnce({ imageKey: 'go-1', isNoGo: false }); + gameMock.recordResponse.mockReturnValueOnce('correct'); + beginTrial(); + clearAllTimers(); + endTrial(); + // Stat should remain at its initial value — not updated on a go trial. + expect(container.querySelector('#os-avg-response').textContent).toBe('--'); + }); + + it('updates avg response stat to a number after a no-go trial', () => { + const container = buildContainer(); + plugin.init(container); + plugin.start(); + gameMock.getAverageResponseMs.mockReturnValue(320); + gameMock.pickNextImage.mockReturnValueOnce({ imageKey: 'no-go', isNoGo: true }); + gameMock.recordResponse.mockReturnValueOnce('correct'); + beginTrial(); + clearAllTimers(); + endTrial(); + expect(container.querySelector('#os-avg-response').textContent).toBe('320'); + }); + + it('updates avg response stat to "--" after a no-go trial when no go response recorded', () => { + const container = buildContainer(); + plugin.init(container); + plugin.start(); + gameMock.getAverageResponseMs.mockReturnValue(null); + gameMock.pickNextImage.mockReturnValueOnce({ imageKey: 'no-go', isNoGo: true }); + gameMock.recordResponse.mockReturnValueOnce('correct'); + beginTrial(); + clearAllTimers(); + endTrial(); + expect(container.querySelector('#os-avg-response').textContent).toBe('--'); + }); }); describe('scheduleNextTrial()', () => { @@ -821,6 +864,64 @@ describe('endTrial() — feedback timer fires after go miss', () => { }); }); +// ── response time recording ─────────────────────────────────────────────────── + +describe('response time recording', () => { + it('calls recordGoResponseTime when Space is pressed on a go trial', () => { + const container = buildContainer(); + plugin.init(container); + plugin.start(); + gameMock.pickNextImage.mockReturnValueOnce({ imageKey: 'go-1.png', isNoGo: false }); + beginTrial(); + clearAllTimers(); + + const event = new KeyboardEvent('keydown', { code: 'Space', bubbles: true, cancelable: true }); + handleKeyDown(event); + + expect(gameMock.recordGoResponseTime).toHaveBeenCalledWith(expect.any(Number)); + }); + + it('does not call recordGoResponseTime for a no-go trial', () => { + const container = buildContainer(); + plugin.init(container); + plugin.start(); + gameMock.pickNextImage.mockReturnValueOnce({ imageKey: 'no-go', isNoGo: true }); + beginTrial(); + clearAllTimers(); + gameMock.recordGoResponseTime.mockClear(); + endTrial(); + + expect(gameMock.recordGoResponseTime).not.toHaveBeenCalled(); + }); + + it('does not call recordGoResponseTime when player misses a go image (no Space press)', () => { + const container = buildContainer(); + plugin.init(container); + plugin.start(); + gameMock.pickNextImage.mockReturnValueOnce({ imageKey: 'go-1.png', isNoGo: false }); + beginTrial(); + clearAllTimers(); + gameMock.recordGoResponseTime.mockClear(); + // endTrial without pressing Space — simulates a miss + endTrial(); + + expect(gameMock.recordGoResponseTime).not.toHaveBeenCalled(); + }); + + it('calls recordGoResponseTime when stimulus area is clicked on a go trial', () => { + const container = buildContainer(); + plugin.init(container); + plugin.start(); + gameMock.pickNextImage.mockReturnValueOnce({ imageKey: 'go-1.png', isNoGo: false }); + beginTrial(); + clearAllTimers(); + + handleClick(); + + expect(gameMock.recordGoResponseTime).toHaveBeenCalledWith(expect.any(Number)); + }); +}); + // ── stop() with window.api present ─────────────────────────────────────────── describe('stop() — window.api IPC call', () => {