Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions app/games/otter-stop/game.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -167,6 +174,7 @@ export function initGame() {
consecutiveWrong = 0;
forceGoNext = false;
speedHistory = [];
goResponseTimes = [];
sequencePosition = 0;
currentSequenceLength = generateSequenceLength();
}
Expand Down Expand Up @@ -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);
}
26 changes: 26 additions & 0 deletions app/games/otter-stop/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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;

Expand Down Expand Up @@ -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();
Expand All @@ -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.
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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');
Expand Down
3 changes: 3 additions & 0 deletions app/games/otter-stop/interface.html
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ <h3 class="os-instructions__subheading">How to Play</h3>
<span class="os-stat">
Interval: <strong id="os-interval">1500</strong> ms
</span>
<span class="os-stat">
Avg Resp: <strong id="os-avg-response">--</strong> ms
</span>
<span class="os-stat">Session: <strong id="os-session-timer" aria-live="polite">00:00</strong></span>
</div>

Expand Down
16 changes: 9 additions & 7 deletions app/games/otter-stop/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
53 changes: 52 additions & 1 deletion app/games/otter-stop/tests/game.test.js
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot There is a test in this file.

Summary of all failing tests
FAIL app/games/otter-stop/tests/game.test.js
● setGoKeys() › pickNextImage() picks from the new keys after setGoKeys()

expect(received).toBe(expected) // Object.is equality

Expected: "custom.png"
Received: "no-go"

  83 |     const { imageKey } = pickNextImage();
  84 |     spy.mockRestore();
> 85 |     expect(imageKey).toBe('custom.png');
     |                      ^
  86 |   });
  87 |
  88 |   it('does not modify NO_GO_KEY', () => {

  at Object.<anonymous> (app/games/otter-stop/tests/game.test.js:85:22)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in commit 153c3a3. After the merge with main, initGame() now calls generateSequenceLength() which can return 0, causing pickNextImage() to immediately return the no-go image. The test now mocks Math.random to 0.5 before calling initGame() (giving currentSequenceLength = 3), then switches it to 0 for the index pick, guaranteeing a go image from the custom keys.

Original file line number Diff line number Diff line change
Expand Up @@ -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). */
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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()', () => {
Expand Down
101 changes: 101 additions & 0 deletions app/games/otter-stop/tests/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
}));
Expand Down Expand Up @@ -122,6 +124,7 @@ function buildContainer() {
<strong id="os-score">0</strong>
<strong id="os-nogo-hits">0</strong>
<strong id="os-interval">1500</strong>
<strong id="os-avg-response">--</strong>
<strong id="os-final-score">0</strong>
<strong id="os-final-best">0</strong>
<strong id="os-final-nogo">0</strong>
Expand Down Expand Up @@ -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()', () => {
Expand Down Expand Up @@ -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', () => {
Expand Down
Loading