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/directional-processing/game.js b/app/games/directional-processing/game.js index 66e79d4..0bf0427 100644 --- a/app/games/directional-processing/game.js +++ b/app/games/directional-processing/game.js @@ -10,6 +10,8 @@ * @file Directional Processing game logic module. */ +import { updateAdaptiveDifficultyState } from '../../components/adaptiveDifficultyService.js'; + /** All valid motion directions for trial generation. */ export const DIRECTIONS = ['up', 'down', 'left', 'right']; @@ -148,23 +150,25 @@ export function recordTrial({ success }) { if (success) { score += 1; - consecutiveCorrect += 1; - consecutiveWrong = 0; - - if (consecutiveCorrect >= CORRECT_STREAK_TO_ADVANCE) { - currentLevel = Math.min(currentLevel + 1, LEVELS.length - 1); - consecutiveCorrect = 0; - } - } else { - consecutiveWrong += 1; - consecutiveCorrect = 0; - - if (consecutiveWrong >= WRONG_STREAK_TO_DROP) { - currentLevel = Math.max(currentLevel - LEVEL_DROP, 0); - consecutiveWrong = 0; - } } + const staircaseState = updateAdaptiveDifficultyState({ + value: currentLevel, + wasCorrect: Boolean(success), + consecutiveCorrect, + consecutiveWrong, + increaseAfter: CORRECT_STREAK_TO_ADVANCE, + decreaseAfter: WRONG_STREAK_TO_DROP, + harderStep: 1, + easierStep: -LEVEL_DROP, + minValue: 0, + maxValue: LEVELS.length - 1, + }); + + currentLevel = staircaseState.value; + consecutiveCorrect = staircaseState.consecutiveCorrect; + consecutiveWrong = staircaseState.consecutiveWrong; + speedHistory.push(LEVELS[currentLevel].displayDurationMs); return { level: currentLevel, consecutiveCorrect, consecutiveWrong }; diff --git a/app/games/fast-piggie/game.js b/app/games/fast-piggie/game.js index aa3bdf4..ff711f9 100644 --- a/app/games/fast-piggie/game.js +++ b/app/games/fast-piggie/game.js @@ -8,6 +8,8 @@ * @file Fast Piggie game logic module. */ +import { updateAdaptiveDifficultyState } from '../../components/adaptiveDifficultyService.js'; + // ── Difficulty constants ─────────────────────────────────────────────────── /** Display duration (ms) at level 0. */ const INITIAL_DISPLAY_MS = 800; @@ -216,11 +218,25 @@ export function calculateWedgeIndex(clickX, clickY, centerX, centerY, radius, we export function addScore(guineaPigsThisRound, answerSpeedMs, displayDurationMs) { score += 1; roundsPlayed += 1; - consecutiveCorrect += 1; - consecutiveWrong = 0; - if (consecutiveCorrect >= 3) { - imageLevel += 1; - consecutiveCorrect = 0; + + const staircaseState = updateAdaptiveDifficultyState({ + value: imageLevel, + wasCorrect: true, + consecutiveCorrect, + consecutiveWrong, + increaseAfter: 3, + decreaseAfter: 3, + harderStep: 1, + easierStep: -2, + minValue: 0, + maxValue: Number.POSITIVE_INFINITY, + }); + + imageLevel = staircaseState.value; + consecutiveCorrect = staircaseState.consecutiveCorrect; + consecutiveWrong = staircaseState.consecutiveWrong; + + if (staircaseState.valueDelta === 1) { if (calculateDisplayDuration(speedLevel) < DISPLAY_STEP_THRESHOLD_MS) { // Sub-threshold phase: alternate between image-only and both. if (speedIncreaseNext) { @@ -259,13 +275,25 @@ export function addScore(guineaPigsThisRound, answerSpeedMs, displayDurationMs) */ export function addMiss(guineaPigsThisRound, displayDurationMs) { roundsPlayed += 1; - consecutiveCorrect = 0; - consecutiveWrong += 1; - if (consecutiveWrong >= 3) { - speedLevel = Math.max(0, speedLevel - 2); + const staircaseState = updateAdaptiveDifficultyState({ + value: speedLevel, + wasCorrect: false, + consecutiveCorrect, + consecutiveWrong, + increaseAfter: 3, + decreaseAfter: 3, + harderStep: 1, + easierStep: -2, + minValue: 0, + maxValue: Number.POSITIVE_INFINITY, + }); + + speedLevel = staircaseState.value; + consecutiveCorrect = staircaseState.consecutiveCorrect; + consecutiveWrong = staircaseState.consecutiveWrong; + if (staircaseState.valueDelta < 0) { imageLevel = canonicalImageLevel(speedLevel); speedIncreaseNext = false; - consecutiveWrong = 0; } // Track most guinea pigs displayed in a round (even if missed) if (typeof guineaPigsThisRound === 'number' && guineaPigsThisRound > mostGuineaPigs) { diff --git a/app/games/field-of-view/game.js b/app/games/field-of-view/game.js index f6dfb91..4e8c868 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,13 @@ export function getThresholdHistory() { } /** - * Get the current staircase setting (1-up/N-down where N is this return value). + * Get the fixed correct-streak target used for SOA decrease. + * This is the shared cross-game constant rule target (3). * * @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..8891ca1 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. Three mistakes in a row will make the timing easier.'); } if (_feedbackEl) { diff --git a/app/games/field-of-view/tests/game.test.js b/app/games/field-of-view/tests/game.test.js index 71bcf48..5229d9b 100644 --- a/app/games/field-of-view/tests/game.test.js +++ b/app/games/field-of-view/tests/game.test.js @@ -49,7 +49,7 @@ describe('asset specs', () => { }); describe('initGame', () => { - test('defaults to 1-up / 2-down when not configured', () => { + test('defaults to a 3-correct streak target', () => { expect(getDownAfterSuccesses()).toBe(DEFAULT_DOWN_AFTER_SUCCESSES); }); @@ -65,9 +65,9 @@ describe('initGame', () => { expect(getAccuracyBuffer()).toEqual([]); }); - test('supports 1-up / 3-down mode when configured', () => { - initGame({ downAfterSuccesses: 3 }); - expect(getDownAfterSuccesses()).toBe(3); + test('keeps the fixed streak target at 3 even when options are passed', () => { + initGame({ downAfterSuccesses: 99 }); + expect(getDownAfterSuccesses()).toBe(DEFAULT_DOWN_AFTER_SUCCESSES); }); test('clamps accuracy buffer size to 3..5', () => { @@ -127,11 +127,12 @@ describe('getGridSizeForCurrentSoa', () => { }); test('returns 5 when SOA drops to 300ms or lower', () => { - // Two-success chunks reduce SOA by one step each. + // Three-success chunks reduce SOA by one step each. const chunksNeeded = Math.ceil((START_SOA_MS - 300) / DEFAULT_STEP_DOWN_MS); for (let i = 0; i < chunksNeeded; i += 1) { recordTrial({ success: true }); recordTrial({ success: true }); + recordTrial({ success: true }); } expect(getCurrentSoaMs()).toBeLessThanOrEqual(300); @@ -189,7 +190,10 @@ describe('createTrialLayout', () => { }); describe('recordTrial staircase behavior', () => { - test('in 1-up / 2-down mode, two successes step down once', () => { + test('three successes step down once', () => { + recordTrial({ success: true }); + expect(getCurrentSoaMs()).toBe(START_SOA_MS); + recordTrial({ success: true }); expect(getCurrentSoaMs()).toBe(START_SOA_MS); @@ -197,26 +201,39 @@ describe('recordTrial staircase behavior', () => { expect(getCurrentSoaMs()).toBe(Number((START_SOA_MS - DEFAULT_STEP_DOWN_MS).toFixed(2))); }); - test('failure immediately steps SOA up and resets success streak', () => { - recordTrial({ success: true }); + test('three consecutive failures step SOA up by two steps', () => { + recordTrial({ success: false }); + recordTrial({ success: false }); + expect(getCurrentSoaMs()).toBe(START_SOA_MS); + recordTrial({ success: false }); + expect(getCurrentSoaMs()).toBe(Number((START_SOA_MS + (DEFAULT_STEP_UP_MS * 2)).toFixed(2))); + }); - expect(getCurrentSoaMs()).toBe(Number((START_SOA_MS + DEFAULT_STEP_UP_MS).toFixed(2))); + test('failure resets the success streak', () => { + recordTrial({ success: true }); + recordTrial({ success: true }); + recordTrial({ success: false }); + expect(getCurrentSoaMs()).toBe(START_SOA_MS); - // A single success should not step down because streak was reset. + // Two more successes should still not step down because streak was reset. recordTrial({ success: true }); - expect(getCurrentSoaMs()).toBe(Number((START_SOA_MS + DEFAULT_STEP_UP_MS).toFixed(2))); + recordTrial({ success: true }); + expect(getCurrentSoaMs()).toBe(START_SOA_MS); }); test('respects SOA floor and ceiling clamps', () => { for (let i = 0; i < 200; i += 1) { recordTrial({ success: true }); recordTrial({ success: true }); + recordTrial({ success: true }); } expect(getCurrentSoaMs()).toBe(MIN_SOA_MS); for (let i = 0; i < 200; i += 1) { recordTrial({ success: false }); + recordTrial({ success: false }); + recordTrial({ success: false }); } expect(getCurrentSoaMs()).toBe(MAX_SOA_MS); }); diff --git a/app/games/high-speed-memory/game.js b/app/games/high-speed-memory/game.js index 74af5c8..dfa682b 100644 --- a/app/games/high-speed-memory/game.js +++ b/app/games/high-speed-memory/game.js @@ -7,6 +7,8 @@ * @file High Speed Memory game logic module. */ +import { updateAdaptiveDifficultyState } from '../../components/adaptiveDifficultyService.js'; + /** * Filename of the target card that the player must find. * Appears exactly PRIMARY_COUNT times in every grid. @@ -206,12 +208,23 @@ export function addCorrectGroup() { */ export function completeRound() { roundsCompleted += 1; - consecutiveCorrectRounds += 1; - consecutiveWrongRounds = 0; - if (consecutiveCorrectRounds >= ROUNDS_TO_LEVEL_UP) { - level += 1; - consecutiveCorrectRounds = 0; - } + + const staircaseState = updateAdaptiveDifficultyState({ + value: level, + wasCorrect: true, + consecutiveCorrect: consecutiveCorrectRounds, + consecutiveWrong: consecutiveWrongRounds, + increaseAfter: ROUNDS_TO_LEVEL_UP, + decreaseAfter: 3, + harderStep: 1, + easierStep: -2, + minValue: 0, + maxValue: Number.POSITIVE_INFINITY, + }); + + level = staircaseState.value; + consecutiveCorrectRounds = staircaseState.consecutiveCorrect; + consecutiveWrongRounds = staircaseState.consecutiveWrong; speedHistory.push(getDisplayDurationMs(level)); } @@ -221,12 +234,22 @@ export function completeRound() { * After 3 consecutive wrong rounds the level drops by 2 (minimum 0). */ export function resetConsecutiveRounds() { - consecutiveCorrectRounds = 0; - consecutiveWrongRounds += 1; - if (consecutiveWrongRounds >= 3) { - level = Math.max(0, level - 2); - consecutiveWrongRounds = 0; - } + const staircaseState = updateAdaptiveDifficultyState({ + value: level, + wasCorrect: false, + consecutiveCorrect: consecutiveCorrectRounds, + consecutiveWrong: consecutiveWrongRounds, + increaseAfter: ROUNDS_TO_LEVEL_UP, + decreaseAfter: 3, + harderStep: 1, + easierStep: -2, + minValue: 0, + maxValue: Number.POSITIVE_INFINITY, + }); + + level = staircaseState.value; + consecutiveCorrectRounds = staircaseState.consecutiveCorrect; + consecutiveWrongRounds = staircaseState.consecutiveWrong; speedHistory.push(getDisplayDurationMs(level)); } diff --git a/app/games/object-track/game.js b/app/games/object-track/game.js index a1a3f0d..9b666a3 100644 --- a/app/games/object-track/game.js +++ b/app/games/object-track/game.js @@ -7,6 +7,8 @@ * @file Object Track core game logic. */ +import { updateAdaptiveDifficultyState } from '../../components/adaptiveDifficultyService.js'; + // ── Constants ───────────────────────────────────────────────────────────────── /** Minimum level index (zero-based). */ @@ -282,25 +284,28 @@ export function evaluateResponse(inputCircles, selectedIds) { * @returns {{ levelDelta: number, newLevel: number }} Change in level and new level. */ export function recordRoundResult(correct) { - let levelDelta = 0; if (correct) { score++; - consecutiveCorrect++; - consecutiveWrong = 0; - } else { - consecutiveCorrect = 0; - consecutiveWrong++; - } - if (consecutiveCorrect >= CORRECT_TO_ADVANCE) { - level++; - consecutiveCorrect = 0; - levelDelta = 1; - } else if (consecutiveWrong >= WRONG_TO_DROP) { - level = Math.max(0, level - LEVELS_TO_DROP); - consecutiveCorrect = 0; - consecutiveWrong = 0; - levelDelta = -LEVELS_TO_DROP; } + + const staircaseState = updateAdaptiveDifficultyState({ + value: level, + wasCorrect: Boolean(correct), + consecutiveCorrect, + consecutiveWrong, + increaseAfter: CORRECT_TO_ADVANCE, + decreaseAfter: WRONG_TO_DROP, + harderStep: 1, + easierStep: -LEVELS_TO_DROP, + minValue: MIN_LEVEL, + maxValue: Number.POSITIVE_INFINITY, + }); + + level = staircaseState.value; + consecutiveCorrect = staircaseState.consecutiveCorrect; + consecutiveWrong = staircaseState.consecutiveWrong; + const levelDelta = staircaseState.valueDelta; + roundsPlayed++; speedHistory.push(getLevelConfig(level).speedPxPerSec); return { levelDelta, newLevel: level }; diff --git a/app/games/orbit-sprite-memory/game.js b/app/games/orbit-sprite-memory/game.js index 967bd4b..99dbecb 100644 --- a/app/games/orbit-sprite-memory/game.js +++ b/app/games/orbit-sprite-memory/game.js @@ -7,6 +7,8 @@ * @file Orbit Sprite Memory game logic module. */ +import { updateAdaptiveDifficultyState } from '../../components/adaptiveDifficultyService.js'; + /** Number of sprites in the provided 4x2 sheet. */ export const TOTAL_SPRITES = 8; @@ -280,13 +282,23 @@ export function evaluateSelection(round, selectedPositions) { export function recordCorrectRound() { roundsPlayed += 1; score += 1; - consecutiveCorrect += 1; - consecutiveWrong = 0; - if (consecutiveCorrect >= STREAK_TO_LEVEL_UP) { - level += 1; - consecutiveCorrect = 0; - } + const staircaseState = updateAdaptiveDifficultyState({ + value: level, + wasCorrect: true, + consecutiveCorrect, + consecutiveWrong, + increaseAfter: STREAK_TO_LEVEL_UP, + decreaseAfter: 3, + harderStep: 1, + easierStep: -2, + minValue: 0, + maxValue: Number.POSITIVE_INFINITY, + }); + + level = staircaseState.value; + consecutiveCorrect = staircaseState.consecutiveCorrect; + consecutiveWrong = staircaseState.consecutiveWrong; speedHistory.push(getDisplayDurationMs(level)); } @@ -296,12 +308,22 @@ export function recordCorrectRound() { */ export function recordIncorrectRound() { roundsPlayed += 1; - consecutiveCorrect = 0; - consecutiveWrong += 1; - if (consecutiveWrong >= 3) { - level = Math.max(0, level - 2); - consecutiveWrong = 0; - } + const staircaseState = updateAdaptiveDifficultyState({ + value: level, + wasCorrect: false, + consecutiveCorrect, + consecutiveWrong, + increaseAfter: STREAK_TO_LEVEL_UP, + decreaseAfter: 3, + harderStep: 1, + easierStep: -2, + minValue: 0, + maxValue: Number.POSITIVE_INFINITY, + }); + + level = staircaseState.value; + consecutiveCorrect = staircaseState.consecutiveCorrect; + consecutiveWrong = staircaseState.consecutiveWrong; speedHistory.push(getDisplayDurationMs(level)); } diff --git a/app/games/otter-stop/game.js b/app/games/otter-stop/game.js index 1f6aad9..c3e5331 100644 --- a/app/games/otter-stop/game.js +++ b/app/games/otter-stop/game.js @@ -15,6 +15,8 @@ * @file Otter Stop! game logic module. */ +import { updateAdaptiveDifficultyState } from '../../components/adaptiveDifficultyService.js'; + /** The key that identifies the no-go stimulus. */ export const NO_GO_KEY = 'no-go'; @@ -216,7 +218,7 @@ export function pickNextImage() { * * Staircase rules: * - 3 consecutive correct no-go inhibitions → level +1, streak reset - * - 3 consecutive wrong → level −2 (min 0), streak reset + * - 3 consecutive wrong responses → level −2 (min 0), streak reset * * After any wrong outcome, `forceGoNext` is set so that `pickNextImage()` will * guarantee a go stimulus on the very next trial. @@ -230,12 +232,29 @@ export function recordResponse(isNoGo, spacePressed) { const correct = isNoGo ? !spacePressed : spacePressed; + let staircaseState; + if (correct) { score += 1; - consecutiveWrong = 0; - // Only correct no-go inhibitions advance the level-up streak. if (isNoGo) { - consecutiveCorrect += 1; + staircaseState = updateAdaptiveDifficultyState({ + value: level, + wasCorrect: true, + consecutiveCorrect, + consecutiveWrong, + increaseAfter: CORRECT_STREAK_TO_ADVANCE, + decreaseAfter: WRONG_STREAK_TO_DROP, + harderStep: 1, + easierStep: -LEVEL_DROP, + minValue: 0, + maxValue: Number.POSITIVE_INFINITY, + }); + } else { + staircaseState = { + value: level, + consecutiveCorrect, + consecutiveWrong: 0, + }; } } else { if (isNoGo) { @@ -243,20 +262,24 @@ export function recordResponse(isNoGo, spacePressed) { } else { misses += 1; } - consecutiveCorrect = 0; - consecutiveWrong += 1; forceGoNext = true; + staircaseState = updateAdaptiveDifficultyState({ + value: level, + wasCorrect: false, + consecutiveCorrect, + consecutiveWrong, + increaseAfter: CORRECT_STREAK_TO_ADVANCE, + decreaseAfter: WRONG_STREAK_TO_DROP, + harderStep: 1, + easierStep: -LEVEL_DROP, + minValue: 0, + maxValue: Number.POSITIVE_INFINITY, + }); } - // Apply staircase adjustments. - if (consecutiveCorrect >= CORRECT_STREAK_TO_ADVANCE) { - level += 1; - consecutiveCorrect = 0; - } - if (consecutiveWrong >= WRONG_STREAK_TO_DROP) { - level = Math.max(0, level - LEVEL_DROP); - consecutiveWrong = 0; - } + level = staircaseState.value; + consecutiveCorrect = staircaseState.consecutiveCorrect; + consecutiveWrong = staircaseState.consecutiveWrong; speedHistory.push(getCurrentIntervalMs()); diff --git a/app/games/otter-stop/tests/game.test.js b/app/games/otter-stop/tests/game.test.js index e731c38..465d4aa 100644 --- a/app/games/otter-stop/tests/game.test.js +++ b/app/games/otter-stop/tests/game.test.js @@ -124,7 +124,7 @@ describe('initGame()', () => { it('resets level to 0', () => { startGame(); - recordResponse(true, false); // correct no-go inhibition ×3 → level → 1 + recordResponse(true, false); // correct response ×3 → level → 1 recordResponse(true, false); recordResponse(true, false); stopGame(); @@ -428,7 +428,7 @@ describe('recordResponse()', () => { }); it('correct go responses do not advance the level (only no-go inhibitions count)', () => { - recordResponse(false, true); // correct go — should NOT advance streak + recordResponse(false, true); // correct go recordResponse(false, true); recordResponse(false, true); expect(getLevel()).toBe(0); @@ -524,7 +524,7 @@ describe('getCurrentIntervalMs()', () => { }); it('never returns less than 150 ms regardless of level', () => { - // Simulate many correct no-go inhibitions + // Simulate many correct no-go inhibitions. for (let i = 0; i < 300; i += 1) recordResponse(true, false); expect(getCurrentIntervalMs()).toBeGreaterThanOrEqual(150); }); diff --git a/app/games/sound-sweep/game.js b/app/games/sound-sweep/game.js index 50fe56d..899861b 100644 --- a/app/games/sound-sweep/game.js +++ b/app/games/sound-sweep/game.js @@ -10,6 +10,8 @@ * @file Sound Sweep game logic module. */ +import { updateAdaptiveDifficultyState } from '../../components/adaptiveDifficultyService.js'; + /** * All valid two-sweep sequences, encoded as '-' strings. * The first character before the dash is the direction of the first sweep; @@ -154,23 +156,25 @@ export function recordTrial({ success }) { if (success) { score += 1; - consecutiveCorrect += 1; - consecutiveWrong = 0; - - if (consecutiveCorrect >= CORRECT_STREAK_TO_ADVANCE) { - currentLevel = Math.min(currentLevel + 1, LEVELS.length - 1); - consecutiveCorrect = 0; - } - } else { - consecutiveWrong += 1; - consecutiveCorrect = 0; - - if (consecutiveWrong >= WRONG_STREAK_TO_DROP) { - currentLevel = Math.max(currentLevel - LEVEL_DROP, 0); - consecutiveWrong = 0; - } } + const staircaseState = updateAdaptiveDifficultyState({ + value: currentLevel, + wasCorrect: Boolean(success), + consecutiveCorrect, + consecutiveWrong, + increaseAfter: CORRECT_STREAK_TO_ADVANCE, + decreaseAfter: WRONG_STREAK_TO_DROP, + harderStep: 1, + easierStep: -LEVEL_DROP, + minValue: 0, + maxValue: LEVELS.length - 1, + }); + + currentLevel = staircaseState.value; + consecutiveCorrect = staircaseState.consecutiveCorrect; + consecutiveWrong = staircaseState.consecutiveWrong; + speedHistory.push(LEVELS[currentLevel].sweepDurationMs); return { level: currentLevel, consecutiveCorrect, consecutiveWrong };