From 9b78d583fc6a3d6dc601018bc0346b86756e1e84 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Wed, 29 Apr 2026 03:14:19 +0000
Subject: [PATCH 1/4] Initial plan
From 27953b20fb8a1452dba703cb9b12e00152872dea Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Wed, 29 Apr 2026 03:21:14 +0000
Subject: [PATCH 2/4] Add average response time display and fix feedback image
spacing in Otter Stop
Agent-Logs-Url: https://github.com/acrosman/BrainSpeedExercises/sessions/a116c851-04c6-4781-9aa4-261375177946
Co-authored-by: acrosman <2972053+acrosman@users.noreply.github.com>
---
app/games/otter-stop/game.js | 32 ++++++++++
app/games/otter-stop/index.js | 23 +++++++
app/games/otter-stop/interface.html | 3 +
app/games/otter-stop/style.css | 16 ++---
app/games/otter-stop/tests/game.test.js | 47 +++++++++++++++
app/games/otter-stop/tests/index.test.js | 77 ++++++++++++++++++++++++
6 files changed, 191 insertions(+), 7 deletions(-)
diff --git a/app/games/otter-stop/game.js b/app/games/otter-stop/game.js
index fa5dcad..088466d 100644
--- a/app/games/otter-stop/game.js
+++ b/app/games/otter-stop/game.js
@@ -111,6 +111,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
@@ -136,6 +143,7 @@ export function initGame() {
consecutiveWrong = 0;
forceGoNext = false;
speedHistory = [];
+ goResponseTimes = [];
}
/**
@@ -390,3 +398,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 0076809..e92442e 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;
@@ -160,6 +169,10 @@ export function updateStats() {
if (_scoreEl) _scoreEl.textContent = game.getScore();
if (_nogoHitsEl) _nogoHitsEl.textContent = game.getNoGoHits();
if (_intervalEl) _intervalEl.textContent = game.getCurrentIntervalMs();
+ if (_avgResponseEl) {
+ const avgMs = game.getAverageResponseMs();
+ _avgResponseEl.textContent = avgMs !== null ? avgMs : '--';
+ }
}
/**
@@ -276,6 +289,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();
@@ -319,6 +338,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 +442,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 7e7554f..448a622 100644
--- a/app/games/otter-stop/tests/game.test.js
+++ b/app/games/otter-stop/tests/game.test.js
@@ -24,6 +24,8 @@ import {
GO_KEYS,
setGoKeys,
getSpeedHistory,
+ recordGoResponseTime,
+ getAverageResponseMs,
} from '../game.js';
/** Default go keys used by the test suite (matches built-in defaults). */
@@ -630,3 +632,48 @@ describe('getSpeedHistory()', () => {
expect(getSpeedHistory()).toEqual([]);
});
});
+
+// ── 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 (rounds down 300.5)
+ });
+
+ 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);
+ });
+});
diff --git a/app/games/otter-stop/tests/index.test.js b/app/games/otter-stop/tests/index.test.js
index 2c5613e..cac04d2 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
@@ -349,6 +352,22 @@ describe('updateStats()', () => {
updateStats();
expect(container.querySelector('#os-interval').textContent).toBe('500');
});
+
+ it('shows "--" for avg response when getAverageResponseMs returns null', () => {
+ const container = buildContainer();
+ plugin.init(container);
+ gameMock.getAverageResponseMs.mockReturnValue(null);
+ updateStats();
+ expect(container.querySelector('#os-avg-response').textContent).toBe('--');
+ });
+
+ it('shows the numeric value when getAverageResponseMs returns a number', () => {
+ const container = buildContainer();
+ plugin.init(container);
+ gameMock.getAverageResponseMs.mockReturnValue(320);
+ updateStats();
+ expect(container.querySelector('#os-avg-response').textContent).toBe('320');
+ });
});
// ── showImage / hideImage ─────────────────────────────────────────────────────
@@ -821,6 +840,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', () => {
From 83a4e646dabf2fcfd6a403b3fd4767daf20eb1b2 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Wed, 29 Apr 2026 03:22:25 +0000
Subject: [PATCH 3/4] Fix incorrect comment in test for getAverageResponseMs
rounding
Agent-Logs-Url: https://github.com/acrosman/BrainSpeedExercises/sessions/a116c851-04c6-4781-9aa4-261375177946
Co-authored-by: acrosman <2972053+acrosman@users.noreply.github.com>
---
app/games/otter-stop/tests/game.test.js | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/app/games/otter-stop/tests/game.test.js b/app/games/otter-stop/tests/game.test.js
index 448a622..f9bf6a5 100644
--- a/app/games/otter-stop/tests/game.test.js
+++ b/app/games/otter-stop/tests/game.test.js
@@ -654,7 +654,7 @@ describe('getAverageResponseMs()', () => {
it('rounds to the nearest millisecond', () => {
recordGoResponseTime(300);
recordGoResponseTime(301);
- expect(getAverageResponseMs()).toBe(301); // Math.round(601 / 2) = 301 (rounds down 300.5)
+ expect(getAverageResponseMs()).toBe(301); // Math.round(601 / 2) = 301
});
it('resets to null after initGame()', () => {
From 153c3a3dab21c457fca4252cab8ba86bdf6de498 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Wed, 29 Apr 2026 04:10:30 +0000
Subject: [PATCH 4/4] Fix failing setGoKeys test and move avg-response stat
update to end of sequence only
Agent-Logs-Url: https://github.com/acrosman/BrainSpeedExercises/sessions/409342e8-51be-454f-95a0-6295f7d93d83
Co-authored-by: acrosman <2972053+acrosman@users.noreply.github.com>
---
app/games/otter-stop/index.js | 11 +++--
app/games/otter-stop/tests/game.test.js | 6 ++-
app/games/otter-stop/tests/index.test.js | 56 +++++++++++++++++-------
3 files changed, 52 insertions(+), 21 deletions(-)
diff --git a/app/games/otter-stop/index.js b/app/games/otter-stop/index.js
index b89f835..9671e27 100644
--- a/app/games/otter-stop/index.js
+++ b/app/games/otter-stop/index.js
@@ -169,10 +169,6 @@ export function updateStats() {
if (_scoreEl) _scoreEl.textContent = game.getScore();
if (_nogoHitsEl) _nogoHitsEl.textContent = game.getNoGoHits();
if (_intervalEl) _intervalEl.textContent = game.getCurrentIntervalMs();
- if (_avgResponseEl) {
- const avgMs = game.getAverageResponseMs();
- _avgResponseEl.textContent = avgMs !== null ? avgMs : '--';
- }
}
/**
@@ -303,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.
diff --git a/app/games/otter-stop/tests/game.test.js b/app/games/otter-stop/tests/game.test.js
index 72dba12..0989df1 100644
--- a/app/games/otter-stop/tests/game.test.js
+++ b/app/games/otter-stop/tests/game.test.js
@@ -79,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');
diff --git a/app/games/otter-stop/tests/index.test.js b/app/games/otter-stop/tests/index.test.js
index cac04d2..2acbc7e 100644
--- a/app/games/otter-stop/tests/index.test.js
+++ b/app/games/otter-stop/tests/index.test.js
@@ -352,22 +352,6 @@ describe('updateStats()', () => {
updateStats();
expect(container.querySelector('#os-interval').textContent).toBe('500');
});
-
- it('shows "--" for avg response when getAverageResponseMs returns null', () => {
- const container = buildContainer();
- plugin.init(container);
- gameMock.getAverageResponseMs.mockReturnValue(null);
- updateStats();
- expect(container.querySelector('#os-avg-response').textContent).toBe('--');
- });
-
- it('shows the numeric value when getAverageResponseMs returns a number', () => {
- const container = buildContainer();
- plugin.init(container);
- gameMock.getAverageResponseMs.mockReturnValue(320);
- updateStats();
- expect(container.querySelector('#os-avg-response').textContent).toBe('320');
- });
});
// ── showImage / hideImage ─────────────────────────────────────────────────────
@@ -671,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()', () => {