From 141166e7eebb4fe81580807a245a8ec8eb42193a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 17 Apr 2026 02:13:33 +0000 Subject: [PATCH 1/9] Initial plan From 8f83863325b3c5ecfca1e59398efd5d9bfe1632d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 17 Apr 2026 02:20:39 +0000 Subject: [PATCH 2/9] fix: enforce shared 3-correct/3-wrong adaptive difficulty behavior Agent-Logs-Url: https://github.com/acrosman/BrainSpeedExercises/sessions/41a69fec-d33c-4e8b-8de0-3451fb05edff Co-authored-by: acrosman <2972053+acrosman@users.noreply.github.com> --- app/components/adaptiveDifficultyService.js | 83 ++++++++++ .../tests/adaptiveDifficultyService.test.js | 156 ++++++++++++++++++ app/games/field-of-view/game.js | 57 ++++--- app/games/field-of-view/index.js | 2 +- app/games/field-of-view/interface.html | 2 +- app/games/field-of-view/tests/game.test.js | 39 +++-- app/games/otter-stop/game.js | 43 ++--- app/games/otter-stop/tests/game.test.js | 62 +++---- 8 files changed, 354 insertions(+), 90 deletions(-) create mode 100644 app/components/adaptiveDifficultyService.js create mode 100644 app/components/tests/adaptiveDifficultyService.test.js diff --git a/app/components/adaptiveDifficultyService.js b/app/components/adaptiveDifficultyService.js new file mode 100644 index 0000000..91287ef --- /dev/null +++ b/app/components/adaptiveDifficultyService.js @@ -0,0 +1,83 @@ +/** + * adaptiveDifficultyService.js — Shared adaptive difficulty staircase helpers. + * + * Applies the common BrainSpeedExercises rule: + * - 3 consecutive correct results increase difficulty by one step. + * - 3 consecutive wrong results decrease difficulty by two steps. + * + * @file Shared adaptive staircase service. + */ + +/** + * Clamp a numeric difficulty value to an inclusive [min, max] range. + * + * @param {number} value - Value to clamp. + * @param {number} minValue - Lower bound. + * @param {number} maxValue - Upper bound. + * @returns {number} Clamped value. + */ +export function clampDifficultyValue(value, minValue, maxValue) { + return Math.min(Math.max(value, minValue), maxValue); +} + +/** + * Apply one adaptive-staircase update for a completed trial/round. + * + * The caller provides: + * - Current difficulty value (level, SOA, interval, etc.) + * - Current consecutive correct/wrong counters + * - Whether this result was correct + * - Numeric step direction/magnitude for harder/easier adjustments + * + * @param {{ + * value: number, + * wasCorrect: boolean, + * consecutiveCorrect: number, + * consecutiveWrong: number, + * increaseAfter?: number, + * decreaseAfter?: number, + * harderStep?: number, + * easierStep?: number, + * minValue?: number, + * maxValue?: number, + * }} state + * @returns {{ + * value: number, + * consecutiveCorrect: number, + * consecutiveWrong: number, + * valueDelta: number, + * }} + */ +export function updateAdaptiveDifficultyState(state) { + const { + value, + wasCorrect, + consecutiveCorrect, + consecutiveWrong, + increaseAfter = 3, + decreaseAfter = 3, + harderStep = 1, + easierStep = -2, + minValue = Number.NEGATIVE_INFINITY, + maxValue = Number.POSITIVE_INFINITY, + } = state; + + let nextValue = value; + let nextConsecutiveCorrect = wasCorrect ? consecutiveCorrect + 1 : 0; + let nextConsecutiveWrong = wasCorrect ? 0 : consecutiveWrong + 1; + + if (nextConsecutiveCorrect >= increaseAfter) { + nextValue = clampDifficultyValue(value + harderStep, minValue, maxValue); + nextConsecutiveCorrect = 0; + } else if (nextConsecutiveWrong >= decreaseAfter) { + nextValue = clampDifficultyValue(value + easierStep, minValue, maxValue); + nextConsecutiveWrong = 0; + } + + return { + value: nextValue, + consecutiveCorrect: nextConsecutiveCorrect, + consecutiveWrong: nextConsecutiveWrong, + valueDelta: nextValue - value, + }; +} diff --git a/app/components/tests/adaptiveDifficultyService.test.js b/app/components/tests/adaptiveDifficultyService.test.js new file mode 100644 index 0000000..21baa8a --- /dev/null +++ b/app/components/tests/adaptiveDifficultyService.test.js @@ -0,0 +1,156 @@ +/** + * adaptiveDifficultyService.test.js — Unit tests for adaptive difficulty helpers. + * + * @file Tests for adaptiveDifficultyService.js + */ + +import { describe, it, expect } from '@jest/globals'; + +import { + clampDifficultyValue, + updateAdaptiveDifficultyState, +} from '../adaptiveDifficultyService.js'; + +describe('clampDifficultyValue()', () => { + it('returns the value when already in range', () => { + expect(clampDifficultyValue(3, 0, 10)).toBe(3); + }); + + it('clamps to the minimum when below range', () => { + expect(clampDifficultyValue(-1, 0, 10)).toBe(0); + }); + + it('clamps to the maximum when above range', () => { + expect(clampDifficultyValue(11, 0, 10)).toBe(10); + }); +}); + +describe('updateAdaptiveDifficultyState()', () => { + it('increments correct streak and resets wrong streak on correct result', () => { + const state = updateAdaptiveDifficultyState({ + value: 2, + wasCorrect: true, + consecutiveCorrect: 1, + consecutiveWrong: 2, + }); + + expect(state).toMatchObject({ + value: 2, + consecutiveCorrect: 2, + consecutiveWrong: 0, + valueDelta: 0, + }); + }); + + it('increments wrong streak and resets correct streak on wrong result', () => { + const state = updateAdaptiveDifficultyState({ + value: 2, + wasCorrect: false, + consecutiveCorrect: 2, + consecutiveWrong: 1, + }); + + expect(state).toMatchObject({ + value: 2, + consecutiveCorrect: 0, + consecutiveWrong: 2, + valueDelta: 0, + }); + }); + + it('applies harderStep after the correct streak threshold', () => { + const state = updateAdaptiveDifficultyState({ + value: 2, + wasCorrect: true, + consecutiveCorrect: 2, + consecutiveWrong: 0, + increaseAfter: 3, + harderStep: 1, + minValue: 0, + maxValue: 10, + }); + + expect(state).toMatchObject({ + value: 3, + consecutiveCorrect: 0, + consecutiveWrong: 0, + valueDelta: 1, + }); + }); + + it('applies easierStep after the wrong streak threshold', () => { + const state = updateAdaptiveDifficultyState({ + value: 4, + wasCorrect: false, + consecutiveCorrect: 0, + consecutiveWrong: 2, + decreaseAfter: 3, + easierStep: -2, + minValue: 0, + maxValue: 10, + }); + + expect(state).toMatchObject({ + value: 2, + consecutiveCorrect: 0, + consecutiveWrong: 0, + valueDelta: -2, + }); + }); + + it('uses default 3/3 and +1/-2 staircase parameters', () => { + const levelUpState = updateAdaptiveDifficultyState({ + value: 0, + wasCorrect: true, + consecutiveCorrect: 2, + consecutiveWrong: 0, + minValue: 0, + maxValue: 10, + }); + + const levelDownState = updateAdaptiveDifficultyState({ + value: 3, + wasCorrect: false, + consecutiveCorrect: 0, + consecutiveWrong: 2, + minValue: 0, + maxValue: 10, + }); + + expect(levelUpState.value).toBe(1); + expect(levelDownState.value).toBe(1); + }); + + it('clamps harder/easier adjustments at provided bounds', () => { + const clampedUp = updateAdaptiveDifficultyState({ + value: 10, + wasCorrect: true, + consecutiveCorrect: 2, + consecutiveWrong: 0, + harderStep: 1, + minValue: 0, + maxValue: 10, + }); + + const clampedDown = updateAdaptiveDifficultyState({ + value: 0, + wasCorrect: false, + consecutiveCorrect: 0, + consecutiveWrong: 2, + easierStep: -2, + minValue: 0, + maxValue: 10, + }); + + expect(clampedUp).toMatchObject({ + value: 10, + consecutiveCorrect: 0, + valueDelta: 0, + }); + expect(clampedDown).toMatchObject({ + value: 0, + consecutiveWrong: 0, + valueDelta: 0, + }); + }); +}); diff --git a/app/games/field-of-view/game.js b/app/games/field-of-view/game.js index f6dfb91..b85ac06 100644 --- a/app/games/field-of-view/game.js +++ b/app/games/field-of-view/game.js @@ -7,6 +7,8 @@ * @file Field of View game logic module. */ +import { updateAdaptiveDifficultyState } from '../../components/adaptiveDifficultyService.js'; + /** Start value for stimulus onset asynchrony in ms. */ export const START_SOA_MS = 500; @@ -17,13 +19,13 @@ export const MIN_SOA_MS = 16.67; export const MAX_SOA_MS = 1000; /** - * Step increase on failure for 1-up staircase behavior. + * Step increase on failure for easier-mode adjustments. * Uses one 60 Hz frame equivalent. */ export const DEFAULT_STEP_UP_MS = 16.67; /** - * Step decrease once success streak target is met. + * Step decrease once the correct streak target is met. * Uses one 60 Hz frame equivalent. */ export const DEFAULT_STEP_DOWN_MS = 16.67; @@ -31,8 +33,8 @@ export const DEFAULT_STEP_DOWN_MS = 16.67; /** Number of recent trials retained for local accuracy view. */ export const DEFAULT_ACCURACY_BUFFER_SIZE = 5; -/** Successes required before stepping down SOA in 1-up/N-down. */ -export const DEFAULT_DOWN_AFTER_SUCCESSES = 2; +/** Correct responses required before increasing difficulty. */ +export const DEFAULT_DOWN_AFTER_SUCCESSES = 3; /** Grid sizes used by the game. */ export const GRID_SIZES = [3, 5]; @@ -74,10 +76,10 @@ let stepUpMs = DEFAULT_STEP_UP_MS; let stepDownMs = DEFAULT_STEP_DOWN_MS; /** @type {number} */ -let downAfterSuccesses = DEFAULT_DOWN_AFTER_SUCCESSES; +let successCounter = 0; /** @type {number} */ -let successCounter = 0; +let consecutiveWrong = 0; /** @type {Array} */ let accuracyBuffer = []; @@ -116,7 +118,6 @@ function clamp(value, min, max) { * Initialize or reset game state. * * @param {{ - * downAfterSuccesses?: number, * stepUpMs?: number, * stepDownMs?: number, * accuracyBufferSize?: number, @@ -126,18 +127,12 @@ export function initGame(options = {}) { running = false; currentSoaMs = START_SOA_MS; successCounter = 0; + consecutiveWrong = 0; trialsCompleted = 0; successes = 0; startTimeMs = null; thresholdHistory = []; - // Configure staircase success threshold: use a validated integer, defaulting when invalid. - if (Number.isFinite(options.downAfterSuccesses)) { - downAfterSuccesses = Math.max(1, Math.round(options.downAfterSuccesses)); - } else { - downAfterSuccesses = DEFAULT_DOWN_AFTER_SUCCESSES; - } - // Configure step sizes with numeric validation and clamping to keep pacing reasonable. const rawStepUp = Number.isFinite(options.stepUpMs) ? options.stepUpMs : DEFAULT_STEP_UP_MS; stepUpMs = clamp(rawStepUp, MIN_SOA_MS, MAX_SOA_MS); @@ -265,6 +260,10 @@ export function createTrialLayout() { /** * Record the outcome of one trial and apply adaptive staircase updates. * + * Rule set: + * - 3 consecutive correct responses decrease SOA by one step (harder). + * - 3 consecutive wrong responses increase SOA by two steps (easier). + * * @param {{ success: boolean }} outcome * @returns {{ thresholdMs: number, recentAccuracy: number, successCounter: number }} */ @@ -274,17 +273,25 @@ export function recordTrial(outcome) { trialsCompleted += 1; if (wasSuccess) { successes += 1; - successCounter += 1; - - if (successCounter >= downAfterSuccesses) { - currentSoaMs = clamp(currentSoaMs - stepDownMs, MIN_SOA_MS, MAX_SOA_MS); - successCounter = 0; - } - } else { - currentSoaMs = clamp(currentSoaMs + stepUpMs, MIN_SOA_MS, MAX_SOA_MS); - successCounter = 0; } + const staircaseState = updateAdaptiveDifficultyState({ + value: currentSoaMs, + wasCorrect: wasSuccess, + consecutiveCorrect: successCounter, + consecutiveWrong, + increaseAfter: DEFAULT_DOWN_AFTER_SUCCESSES, + decreaseAfter: 3, + harderStep: -stepDownMs, + easierStep: stepUpMs * 2, + minValue: MIN_SOA_MS, + maxValue: MAX_SOA_MS, + }); + + currentSoaMs = staircaseState.value; + successCounter = staircaseState.consecutiveCorrect; + consecutiveWrong = staircaseState.consecutiveWrong; + accuracyBuffer.push(wasSuccess); if (accuracyBuffer.length > accuracyBufferSize) { accuracyBuffer = accuracyBuffer.slice(-accuracyBufferSize); @@ -344,12 +351,12 @@ export function getThresholdHistory() { } /** - * Get the current staircase setting (1-up/N-down where N is this return value). + * Get the current correct-streak target used for SOA decrease. * * @returns {number} */ export function getDownAfterSuccesses() { - return downAfterSuccesses; + return DEFAULT_DOWN_AFTER_SUCCESSES; } /** diff --git a/app/games/field-of-view/index.js b/app/games/field-of-view/index.js index 22ddfad..e6d7a29 100644 --- a/app/games/field-of-view/index.js +++ b/app/games/field-of-view/index.js @@ -424,7 +424,7 @@ function submitResponse() { if (success) { announce('Correct. SOA may decrease after the success streak target is met.'); } else { - announce('Incorrect. SOA increased by one step to keep challenge near threshold.'); + announce('Incorrect. SOA increased by two steps after repeated errors.'); } if (_feedbackEl) { diff --git a/app/games/field-of-view/interface.html b/app/games/field-of-view/interface.html index 35cb08c..ad495b9 100644 --- a/app/games/field-of-view/interface.html +++ b/app/games/field-of-view/interface.html @@ -11,7 +11,7 @@

How to Play

  • Memorize which kitten appeared in the center.
  • Memorize where the toy appeared in the periphery (outer edge).
  • After the field mask, select the kitten and click the toy location.
  • -
  • The game adapts your display time with a 1-up / 2-down staircase.
  • +
  • The game adapts your display time with a 3-correct-up / 3-wrong-down-by-2 staircase.